Compare commits

..

98 Commits

Author SHA1 Message Date
leave330
d9ac2fa9ed feat(event): list valid domains in the --domain flag help 2026-08-01 17:17:12 +08:00
leave330
88a93254c0 docs(event): tighten the dry-run guidance the eval verified 2026-08-01 17:11:51 +08:00
leave330
569a5d1c5c test(event): close the review's gate gaps and defensive holes
Frozen baseline outputs now validate against their declared schemas;
process-error diagnostics truncate before payload fragments can reach
stderr; the render contract gate recurses into nested named types; the
snapshot deep-copies field-override enums; hosts are gated against
importing the platform SDK; a credential-vocabulary guard keeps future
sensitive parameters inside the redaction net.
2026-08-01 16:45:02 +08:00
leave330
0ab9b1ab90 fix(event): honest dry-run preconditions and single-run param normalization
An unusable credential now renders a blocked decision instead of failing
the dry-run; an unreadable scope ledger reports unknown instead of ok;
the decision's normalized params flow into the real run so the
normalization hook keeps its once-per-consumer contract; execute guards
against mismatched entry/decision pairs and key/definition splits.
2026-08-01 16:29:51 +08:00
leave330
8a5701ddcf feat(event): reject processless custom schemas and garbage raw schemas at compile time 2026-08-01 16:22:06 +08:00
leave330
b44aaa1cfe docs(event): tell agents to execute the dry-run preview, not just cite it 2026-08-01 12:08:25 +08:00
leave330
db009030ed docs(event): point the vc topic index at the full seven-key catalog
The stale four-key index line steered agents into answering from the
document instead of querying; the live-list example and the optional
preparation-field note close the same gap.
2026-08-01 11:54:48 +08:00
leave330
f6d7c1d601 chore(event): normalize import grouping after the adapter moves 2026-08-01 01:05:26 +08:00
leave330
f6f77817e3 refactor(event): fold duplicated domain preparation and projection helpers 2026-08-01 00:59:35 +08:00
leave330
1da3223403 refactor(event): read canonical metadata instead of re-parsing payload headers
Domain processors now take event id, type, source time, and tenant
identity from the canonical event the pipeline restored; the per-file
header envelope blocks are gone and the header re-parse gate is
zero-tolerance. The per-key stdout baseline is byte-identical.
2026-08-01 00:49:22 +08:00
leave330
5f43dd7b4a test(event): gate dependency direction with self-checking import detectors
Kernel packages are derived from the directory tree so new packages are
gated the day they appear; host adapter imports are pinned by a two-way
allowlist (unexpected imports fail, stale entries fail); the detectors
prove themselves on synthetic violations. Header re-parsing in domain
packages is pinned by a shrink-only baseline until the residue is gone.
2026-08-01 00:38:36 +08:00
leave330
4e07c6b01e docs(event): document dry-run, domain filter and preconsume cleanup classes 2026-08-01 00:27:33 +08:00
leave330
c3e84cdf3a refactor(event): move the websocket source behind the lark adapter boundary 2026-08-01 00:24:09 +08:00
leave330
faa822ecdb refactor(event): move the busdiscover package under the localbus adapter 2026-08-01 00:23:03 +08:00
leave330
f7a821b572 refactor(event): move the busctl package under the localbus adapter 2026-08-01 00:22:59 +08:00
leave330
dafcaad003 refactor(event): move the transport package under the localbus adapter 2026-08-01 00:22:55 +08:00
leave330
e83a24f3fc refactor(event): move the protocol package under the localbus adapter 2026-08-01 00:22:51 +08:00
leave330
085765fb26 refactor(event): let the composition root inject event sources into the bus 2026-08-01 00:22:29 +08:00
leave330
9a9f8da699 feat(event): add a domain filter to event list 2026-08-01 00:17:54 +08:00
leave330
22c54f119b feat(event): add event consume --dry-run with a provable no-side-effect preview 2026-08-01 00:17:54 +08:00
leave330
cfa862f8d0 refactor(event): run consume preparation behind an injectable application seam
The stream host keeps its connect/prepare/stream sequence and panic-safe
cleanup exactly as before; what changed is who supplies the preparation:
the application layer injects the decided strategy, and the declaration's
own hook remains the fallback for direct library callers.
2026-08-01 00:17:54 +08:00
leave330
1a9ee4a4d9 feat(event): add a consume application service with an immutable decision 2026-08-01 00:17:53 +08:00
leave330
83873a2215 refactor(event): route every catalog reader through the compiled snapshot
The runtime registry is gone: declarations aggregate through events.All,
compile once at the command tree's assembly point, and every reader
(list, schema, consume, suggestions, bus) works from the immutable
snapshot. Byte-identical golden output pins the migration.
2026-08-01 00:03:36 +08:00
leave330
77c8255f2c feat(event): aggregate domain declarations through an explicit events.All 2026-08-01 00:03:13 +08:00
leave330
5a3b112fa7 refactor(event): compile event declarations into a validated immutable snapshot 2026-08-01 00:03:13 +08:00
leave330
a5d30fe7dc fix(event): scope whiteboard subscriptions by whiteboard id
The whiteboard schema golden gains the subscription-key marker on
whiteboard_id; that visible diff is the intended outcome of this fix.
2026-07-31 23:26:56 +08:00
leave330
0599501680 fix(event): drop malformed payloads instead of leaking raw envelopes to stdout 2026-07-31 23:26:56 +08:00
leave330
42897d3c5c feat(event): negotiate bus capabilities before any consume side effect 2026-07-31 23:26:56 +08:00
leave330
dbca48892a fix(event): fail closed when a payload header conflicts with canonical metadata 2026-07-31 23:26:56 +08:00
leave330
6e760b921c fix(event): stop backfilling upstream event time from the local clock 2026-07-31 23:04:36 +08:00
leave330
1166b273f7 feat(event): carry full canonical metadata across the local bus frame 2026-07-31 23:04:36 +08:00
leave330
4987cc3273 feat(event): parse tenant identity once at the websocket ingress 2026-07-31 22:58:49 +08:00
leave330
57835186f5 refactor(event): extract the canonical event value type into a model package 2026-07-31 22:56:55 +08:00
leave330
20f9cfd313 test(event): pin the classic PreConsume first/last lifecycle on a real bus 2026-07-31 22:54:26 +08:00
leave330
a930c0d398 test(event): snapshot per-key processed stdout as the compat baseline 2026-07-31 22:52:11 +08:00
leave330
e3b39fad1d test(event): gate list and schema JSON against runtime-field leaks 2026-07-31 22:46:13 +08:00
leave330
9ec5981226 test(event): pin list and schema output with golden files 2026-07-31 22:46:13 +08:00
leave330
b2ba6b2296 test(event): freeze the registered EventKey baseline 2026-07-31 22:46:13 +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
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
412 changed files with 39117 additions and 4067 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

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"

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

@@ -16,12 +16,14 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/adapter/lark/websocket"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/event/catalog"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
var domain string
cmd := &cobra.Command{
@@ -44,7 +46,13 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
tr := transport.New()
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
ingress := &websocket.FeishuSource{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: domain,
Logger: logger,
}
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress)
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()

View File

@@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd := NewCmdBus(f, compileCatalog())
cmd.SetArgs([]string{})
err := cmd.Execute()

View File

@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/cmd/event/render"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
@@ -23,8 +24,10 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -37,9 +40,10 @@ type consumeCmdOpts struct {
maxEvents int
timeout time.Duration
dryRun bool
}
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
var o consumeCmdOpts
cmd := &cobra.Command{
@@ -57,7 +61,7 @@ Use 'event list' to see all available EventKeys.
Use 'event schema <EventKey>' for parameter details.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConsume(cmd, f, args[0], o)
return runConsume(cmd, f, snap, args[0], o)
},
}
@@ -66,6 +70,7 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
@@ -76,7 +81,7 @@ Use 'event schema <EventKey>' for parameter details.`,
return cmd
}
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, eventKey string, o consumeCmdOpts) error {
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
ignoreBrokenPipe()
@@ -90,10 +95,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
return err
}
keyDef, ok := eventlib.Lookup(eventKey)
entry, ok := snap.Resolve(eventKey)
if !ok {
return unknownEventKeyErr(eventKey)
return unknownEventKeyErr(snap, eventKey)
}
keyDef := entry.Definition()
identity, err := resolveIdentity(cmd, f, keyDef)
if err != nil {
@@ -120,9 +126,16 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
domain := core.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon.
// Surface auth errors before forking the bus daemon. A dry run instead
// reports the unusable credential as a blocked precondition: the caller
// asked what would happen, and "a real run would refuse to authenticate"
// is a legitimate part of that answer.
var tokenErr error
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
return err
if !o.dryRun {
return err
}
tokenErr = err
}
apiClient, err := f.NewAPIClient()
@@ -169,11 +182,31 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
svc := &appconsume.Service{
Strategies: consumeStrategies,
Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }),
Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) {
return readPreconditions(ctx, pf, appVerErr, tokenErr), nil
}),
}
req := appconsume.Request{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
OutputDir: outputDir,
DryRun: o.dryRun,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}
decision, err := svc.Decide(cmd.Context(), entry, req, appconsume.ExecutionContext{API: runtime})
if err != nil {
return err
}
if err := preflightScopes(cmd.Context(), pf); err != nil {
return err
if o.dryRun {
return render.WriteDecisionJSON(f.IOStreams.Out, f.IOStreams.ErrOut, string(identity), decision.View())
}
ctx, cancel := context.WithCancel(cmd.Context())
@@ -204,23 +237,26 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
watchStdinEOF(os.Stdin, cancel, errOut)
}
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}); err != nil {
return err
}
return nil
runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error {
return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Def: keyDef,
Params: decision.NormalizedParams(),
ParamsNormalized: true,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
Prepare: prepare,
})
})
return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime})
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
@@ -248,10 +284,14 @@ type preflightCtx struct {
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
// preflightScopes compares required scopes against session-available scopes
// (user: UAT stored; bot: appVer.TenantScopes). checked reports whether a
// comparison actually happened: "the ledger was unavailable" and "the check
// passed" are different answers, and only the caller can decide how loudly to
// say the first one.
func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err error) {
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
return nil
return true, nil
}
if ctx == nil {
ctx = context.Background()
@@ -261,24 +301,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
switch {
case pf.identity.IsBot():
if pf.appVer == nil {
return nil
return false, nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == core.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
return false, nil //nolint:nilerr // best-effort: the bus handshake surfaces the real auth error
}
storedScopes = result.Scopes
default:
return nil
return false, nil
}
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
if len(missing) == 0 {
return nil
return true, nil
}
return errs.NewPermissionError(errs.SubtypeMissingScope,
return true, errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// A dry run in a degraded environment (the test factory has no reachable
// platform, so every weak read-only check comes back unanswered) still exits
// zero with a structured decision that honestly says "unknown" — and performs
// none of its declared write effects.
func TestDryRun_DegradedEnvironmentStaysHonestAndSideEffectFree(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_test"})
snap := compileCatalog()
tmp := t.TempDir()
prevWD, _ := os.Getwd()
if err := os.Chdir(tmp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(prevWD) })
cmd := NewCmdConsume(f, snap)
cmd.SetArgs([]string{"im.message.receive_v1", "--as", "bot", "--dry-run", "--output-dir", "events-out"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run must not fail on unusable credentials, got: %v", err)
}
var envelope struct {
OK bool `json:"ok"`
DryRun bool `json:"dry_run"`
Data struct {
Decision struct {
Status string `json:"status"`
Preconditions []struct {
Name string `json:"name"`
Status string `json:"status"`
} `json:"preconditions"`
WouldWrite []string `json:"would_write"`
} `json:"decision"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || !envelope.DryRun {
t.Errorf("want ok=true dry_run=true, got: %s", stdout.String())
}
if envelope.Data.Decision.Status != "unknown" {
t.Errorf("unanswerable weak checks must render unknown, not fake readiness; got status %q", envelope.Data.Decision.Status)
}
names := map[string]string{}
for _, p := range envelope.Data.Decision.Preconditions {
names[p.Name] = p.Status
}
if names["credentials_available"] == "" || names["console_event_published"] == "" || names["scopes_granted"] == "" {
t.Errorf("preconditions must name every check, got: %v", names)
}
// The declared write side effects must stay declarations: the requested
// output dir must not exist after a dry run.
if _, err := os.Stat(filepath.Join(tmp, "events-out")); !os.IsNotExist(err) {
t.Error("dry-run created the output directory; the preview performed a side effect")
}
}

View File

@@ -18,12 +18,13 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
SilenceUsage: true,
}
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
snap := compileCatalog()
cmd.AddCommand(NewCmdConsume(f, snap))
cmd.AddCommand(NewCmdList(f, snap))
cmd.AddCommand(NewCmdSchema(f, snap))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
cmd.AddCommand(NewCmdBus(f, snap))
return cmd
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/output"
)
@@ -288,9 +288,10 @@ func errorAs(err error, target interface{}) bool {
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
snap := compileCatalog()
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)
cmd := NewCmdConsume(f, snap)
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("consume missing --%s flag", flag)
@@ -320,14 +321,22 @@ func TestNewCmdFactories_WireFlags(t *testing.T) {
})
t.Run("list", func(t *testing.T) {
cmd := NewCmdList(f)
cmd := NewCmdList(f, snap)
if cmd.Flags().Lookup("json") == nil {
t.Error("list missing --json flag")
}
domainFlag := cmd.Flags().Lookup("domain")
if domainFlag == nil {
t.Fatal("list missing --domain flag")
}
wantUsage := "Only list EventKeys of this domain. Valid domains: " + strings.Join(snap.Domains(), ", ")
if domainFlag.Usage != wantUsage {
t.Errorf("--domain usage = %q, want %q", domainFlag.Usage, wantUsage)
}
})
t.Run("bus", func(t *testing.T) {
cmd := NewCmdBus(f)
cmd := NewCmdBus(f, snap)
if !cmd.Hidden {
t.Error("bus should be hidden (internal daemon entrypoint)")
}

81
cmd/event/golden_test.go Normal file
View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing")
// goldenSchemaKeys picks one key per rendering path so every branch of the
// list/schema output stays pinned: a processed key with a flat custom schema,
// a native key with field overrides, a callback key with a single consumer,
// and a key with a required parameter plus a pre-consume hook.
var goldenSchemaKeys = map[string]string{
"schema_im_message_receive": "im.message.receive_v1",
"schema_im_chat_updated": "im.chat.updated_v1",
"schema_card_action_trigger": "card.action.trigger",
"schema_board_whiteboard": "board.whiteboard.updated_v1",
}
// The golden files pin stdout byte-for-byte. The output is deterministic:
// the snapshot keeps keys sorted, encoding/json sorts object keys, and nothing
// on the rendering path reads the clock or randomness. Regenerate with:
//
// go test ./cmd/event/ -run TestGolden -update
func TestGolden_ListOutput(t *testing.T) {
snap := compileCatalog()
for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} {
t.Run(name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "", asJSON); err != nil {
t.Fatalf("runList: %v", err)
}
assertGolden(t, name, stdout.String())
})
}
}
func TestGolden_SchemaOutput(t *testing.T) {
snap := compileCatalog()
for name, key := range goldenSchemaKeys {
for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} {
t.Run(name+suffix, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, snap, key, asJSON); err != nil {
t.Fatalf("runSchema(%s): %v", key, err)
}
assertGolden(t, name+suffix, stdout.String())
})
}
}
}
func assertGolden(t *testing.T, name, got string) {
t.Helper()
path := filepath.Join("testdata", "golden", name+".golden")
if *updateGolden {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("missing golden %s (regenerate with -update): %v", name, err)
}
if string(want) != got {
t.Errorf("output drifted from golden %s\n--- want\n%s\n--- got\n%s", name, want, got)
}
}

View File

@@ -10,31 +10,44 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/output"
)
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
var asJSON bool
var domain string
cmd := &cobra.Command{
Use: "list",
Short: "List all available EventKeys",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --domain to keep one domain only, --json for machine-readable output.",
RunE: func(cmd *cobra.Command, args []string) error {
return runList(f, asJSON)
return runList(f, snap, domain, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
cmd.Flags().StringVar(&domain, "domain", "", fmt.Sprintf(
"Only list EventKeys of this domain. Valid domains: %s",
strings.Join(snap.Domains(), ", "),
))
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runList(f *cmdutil.Factory, asJSON bool) error {
all := eventlib.ListAll()
func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON bool) error {
entries, err := entriesForDomain(snap, domain)
if err != nil {
return err
}
if asJSON {
return writeListJSON(f, all)
return writeListJSON(f, entries)
}
all := make([]*eventlib.KeyDefinition, 0, len(entries))
for _, entry := range entries {
all = append(all, entry.Definition())
}
if len(all) == 0 {
@@ -104,18 +117,43 @@ func runList(f *cmdutil.Factory, asJSON bool) error {
return nil
}
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
type row struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
// listRow is the JSON shape of one `event list --json` row. It is a named
// type (not a function-local literal) so the render contract test can walk
// its fields and reject accidental additions to the public output.
type listRow struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
// entriesForDomain filters at the snapshot query layer: without a domain the
// full catalog comes back untouched; with one, rows are only removed, never
// reshaped. An unknown domain is rejected with the valid set spelled out.
func entriesForDomain(snap *catalog.Snapshot, domain string) ([]*catalog.Entry, error) {
if domain == "" {
return snap.Entries(), nil
}
rows := make([]row, len(all))
for i, def := range all {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
var filtered []*catalog.Entry
for _, entry := range snap.Entries() {
if entry.Descriptor().Domain == domain {
filtered = append(filtered, entry)
}
}
if len(filtered) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown domain: %s", domain).
WithParam("--domain").
WithHint("valid domains: %s", strings.Join(snap.Domains(), ", "))
}
return filtered, nil
}
func writeListJSON(f *cmdutil.Factory, entries []*catalog.Entry) error {
rows := make([]listRow, len(entries))
for i, entry := range entries {
rows[i] = listRow{
KeyDefinition: entry.Definition(),
ResolvedSchema: entry.Output().SchemaJSON,
}
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
}
output.PrintJson(f.IOStreams.Out, rows)
return nil

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// Filtering only removes rows: the vc selection must be exactly the catalog's
// vc keys, and every remaining row keeps the unfiltered field set.
func TestListDomain_FilterKeepsExactlyTheRequestedDomain(t *testing.T) {
snap := compileCatalog()
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "vc", true); err != nil {
t.Fatal(err)
}
var rows []map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
t.Fatal(err)
}
want := map[string]bool{}
for _, key := range snap.Keys() {
if strings.HasPrefix(key, "vc.") {
want[key] = true
}
}
if len(want) == 0 {
t.Fatal("the catalog has no vc keys; the filter test proves nothing")
}
got := map[string]bool{}
for _, row := range rows {
var key string
_ = json.Unmarshal(row["key"], &key)
got[key] = true
for _, field := range []string{"event_type", "schema", "resolved_output_schema"} {
if _, ok := row[field]; !ok {
t.Errorf("%s: filtering must not reshape rows; %q is missing", key, field)
}
}
}
if len(got) != len(want) {
t.Fatalf("filtered rows = %v, want the exact vc set %v", got, want)
}
for key := range want {
if !got[key] {
t.Errorf("vc key missing from the filtered list: %s", key)
}
}
}
func TestListDomain_TextFilter(t *testing.T) {
snap := compileCatalog()
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, snap, "im", false); err != nil {
t.Fatal(err)
}
out := stdout.String()
if !strings.Contains(out, "im.message.receive_v1") {
t.Error("im keys must be listed")
}
for _, foreign := range []string{"vc.", "minutes.", "board.", "approval."} {
if strings.Contains(out, foreign) {
t.Errorf("foreign domain %q leaked into the filtered text output", foreign)
}
}
}
func TestListDomain_UnknownDomainIsRejectedWithTheValidSet(t *testing.T) {
snap := compileCatalog()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runList(f, snap, "definitely-bogus", true)
if err == nil {
t.Fatal("an unknown domain must be rejected")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("want invalid_argument, got %v", err)
}
if !strings.Contains(err.Error(), "unknown domain: definitely-bogus") {
t.Errorf("error must name the rejected value, got %v", err)
}
for _, domain := range []string{"application", "approval", "board", "card", "im", "minutes", "task", "vc"} {
if !strings.Contains(problem.Hint, domain) {
t.Errorf("hint must list valid domain %q, got %q", domain, problem.Hint)
}
}
}

View File

@@ -10,20 +10,18 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
snap := compileCatalog()
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q) should succeed", key)
}
}
}
@@ -31,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, false); err != nil {
if err := runList(f, compileCatalog(), "", false); err != nil {
t.Fatalf("runList: %v", err)
}
@@ -55,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) {
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, true); err != nil {
if err := runList(f, compileCatalog(), "", true); err != nil {
t.Fatalf("runList json: %v", err)
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
)
func preconditionByName(list []appconsume.Precondition, name string) *appconsume.Precondition {
for i := range list {
if list[i].Name == name {
return &list[i]
}
}
return nil
}
// An unusable credential blocks the decision and carries the exact error a
// real run would have returned, so both paths refuse for the same reason.
func TestReadPreconditions_TokenErrorBlocksWithTheSameError(t *testing.T) {
tokenErr := errors.New("no tenant token available")
pf := &preflightCtx{
appID: "cli_test",
identity: core.AsBot,
keyDef: &eventlib.KeyDefinition{Key: "demo.thing.updated_v1"},
}
got := readPreconditions(context.Background(), pf, nil, tokenErr)
cred := preconditionByName(got, "credentials_available")
if cred == nil {
t.Fatal("credentials_available precondition missing")
}
if cred.Status != appconsume.PreconditionBlocked || !errors.Is(cred.BlockErr, tokenErr) {
t.Errorf("token failure must block with the original error, got %+v", cred)
}
}
// A scope ledger nobody could read is reported as unknown — never as ok.
func TestReadPreconditions_UnreadableScopesAreUnknown(t *testing.T) {
pf := &preflightCtx{
appID: "cli_test",
identity: core.AsBot,
keyDef: &eventlib.KeyDefinition{
Key: "demo.thing.updated_v1",
Scopes: []string{"demo:read"},
},
appVer: nil, // no published version: the bot scope ledger is unreadable
}
got := readPreconditions(context.Background(), pf, nil, nil)
scopes := preconditionByName(got, "scopes_granted")
if scopes == nil {
t.Fatal("scopes_granted precondition missing")
}
if scopes.Status != appconsume.PreconditionUnknown {
t.Errorf("an unreadable ledger must report unknown, got %q", scopes.Status)
}
}

View File

@@ -108,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
@@ -124,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
"im:message.group_at_msg",
"contact:user:readonly",
}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
@@ -136,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
@@ -169,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
if _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package render turns consume decisions into user-facing output. It is the
// only place a decision becomes JSON; the application layer never formats
// anything itself.
package render
import (
"io"
"regexp"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/output"
)
// sensitiveParamName matches parameter names whose values must never be
// echoed back in a rendered decision. Names are matched, not values: a
// credential-bearing parameter is identifiable by its declaration, and
// guessing at value shapes would miss more than it catches.
var sensitiveParamName = regexp.MustCompile(`(?i)(token|secret|password|credential|cookie)`)
func redactParams(params map[string]string) map[string]string {
out := make(map[string]string, len(params))
for name, value := range params {
if sensitiveParamName.MatchString(name) {
out[name] = "[redacted]"
continue
}
out[name] = value
}
return out
}
// decisionPayload is the JSON shape under data.decision — snake_case, stable,
// documented in the event skill. Field additions must be additive.
type decisionPayload struct {
EventKey string `json:"event_key"`
Domain string `json:"domain"`
Identity string `json:"identity"`
Status string `json:"status"`
Params map[string]string `json:"params"`
Scope string `json:"scope"`
Preconditions []preconditionView `json:"preconditions"`
Preparation *preparationView `json:"preparation,omitempty"`
WouldRead []string `json:"would_read"`
WouldWrite []string `json:"would_write"`
}
type preconditionView struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
type preparationView struct {
Strategy string `json:"strategy"`
Condition string `json:"condition"`
Action string `json:"action"`
}
// WriteDecisionJSON emits the decision inside the standard success envelope
// with the envelope's own top-level dry_run marker set.
func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.DecisionView) error {
return output.WriteSuccessEnvelope(map[string]any{
"decision": toPayload(v),
}, output.SuccessEnvelopeOptions{
CommandPath: "event consume",
Identity: identity,
DryRun: true,
Out: out,
ErrOut: errOut,
})
}
func toPayload(v appconsume.DecisionView) decisionPayload {
p := decisionPayload{
EventKey: v.EventKey,
Domain: v.Domain,
Identity: v.Identity,
Status: v.Status,
Params: redactParams(v.Params),
Scope: v.Scope,
WouldRead: v.WouldRead,
WouldWrite: v.WouldWrite,
}
p.Preconditions = make([]preconditionView, 0, len(v.Preconditions))
for _, pc := range v.Preconditions {
p.Preconditions = append(p.Preconditions, preconditionView(pc))
}
if v.Preparation != nil {
pv := preparationView(*v.Preparation)
p.Preparation = &pv
}
return p
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package render
import (
"bytes"
"encoding/json"
"strings"
"testing"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
)
func sampleView() appconsume.DecisionView {
return appconsume.DecisionView{
EventKey: "vc.note.generated_v1",
Domain: "vc",
Identity: "user",
Status: "ready",
Params: map[string]string{"whiteboard_id": "wb-1", "access_token": "sk-SENSITIVE-VALUE"},
Scope: "vc.note.generated_v1",
Preconditions: []appconsume.PreconditionView{
{Name: "console_event_published", Status: "ok"},
{Name: "scopes_granted", Status: "ok"},
},
Preparation: &appconsume.PreparationView{
Strategy: "legacy_preconsume", Condition: "first_consumer_for_scope", Action: "register_event_delivery",
},
WouldRead: []string{"local_bus_probe", "app_metadata_preflight"},
WouldWrite: []string{"start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"},
}
}
// The JSON contract: dry_run is the envelope's own top-level marker (never a
// data field), and the decision sits under data.decision with its documented
// members.
func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) {
var out, errOut bytes.Buffer
if err := WriteDecisionJSON(&out, &errOut, "user", sampleView()); err != nil {
t.Fatal(err)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String())
}
if string(envelope["ok"]) != "true" || string(envelope["dry_run"]) != "true" {
t.Errorf("envelope must carry top-level ok=true and dry_run=true, got %s", out.String())
}
if _, misplaced := envelope["decision"]; misplaced {
t.Error("decision must live under data, not at the envelope top level")
}
var data struct {
Decision struct {
EventKey string `json:"event_key"`
Domain string `json:"domain"`
Identity string `json:"identity"`
Status string `json:"status"`
Params map[string]string `json:"params"`
Scope string `json:"scope"`
Preparation *struct {
Strategy string `json:"strategy"`
Condition string `json:"condition"`
Action string `json:"action"`
} `json:"preparation"`
WouldRead []string `json:"would_read"`
WouldWrite []string `json:"would_write"`
DryRun *bool `json:"dry_run"`
} `json:"decision"`
}
if err := json.Unmarshal(envelope["data"], &data); err != nil {
t.Fatalf("data.decision does not match the documented shape: %v", err)
}
d := data.Decision
if d.EventKey != "vc.note.generated_v1" || d.Domain != "vc" || d.Identity != "user" || d.Status != "ready" {
t.Errorf("identity facts drifted: %+v", d)
}
if d.Preparation == nil || d.Preparation.Condition != "first_consumer_for_scope" {
t.Errorf("conditional preparation must be stated: %+v", d.Preparation)
}
if len(d.WouldRead) == 0 || len(d.WouldWrite) == 0 {
t.Error("would_read / would_write must be present")
}
if d.DryRun != nil {
t.Error("dry_run inside data.decision would duplicate the envelope marker")
}
}
// Sensitive parameter values never reach the rendered output. The control
// assertion first proves the sentinel would be visible if leaked.
func TestWriteDecision_RedactsSensitiveParams(t *testing.T) {
const sentinel = "sk-SENSITIVE-VALUE"
view := sampleView()
if !strings.Contains(view.Params["access_token"], sentinel) {
t.Fatal("control failed: the sentinel is not in the input, the test cannot prove redaction")
}
var jsonOut, jsonErr bytes.Buffer
if err := WriteDecisionJSON(&jsonOut, &jsonErr, "user", view); err != nil {
t.Fatal(err)
}
if strings.Contains(jsonOut.String(), sentinel) {
t.Errorf("JSON output leaks a sensitive param value: %s", jsonOut.String())
}
compact := strings.ReplaceAll(strings.ReplaceAll(jsonOut.String(), "\n", ""), " ", "")
if !strings.Contains(compact, `"access_token":"[redacted]"`) {
t.Errorf("sensitive param must render as redacted, got: %s", jsonOut.String())
}
if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) {
t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String())
}
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package render
import (
"strings"
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// This file is a guard, not a contract: it does not pin what the redaction
// regex matches, it hunts for declared parameter names that smell like
// credentials yet would render verbatim. The detector wordlist is therefore
// deliberately wider than the production sensitiveParamName pattern — a hit
// here means either the parameter should be renamed or the production
// pattern must grow, decided by a human, never by loosening this list.
// credentialWords are matched against whole '_'/'-'/'.'-separated segments of
// a parameter name, so chat_key or tokenizer_mode cannot trip them. The bare
// word "key" is intentionally absent (identifier names like whiteboard_id or
// a hypothetical chat_key are not credentials); the api/key pairing is what
// carries credential semantics and is detected as a pair below.
var credentialWords = map[string]bool{
"token": true,
"secret": true,
"password": true,
"credential": true,
"credentials": true,
"cookie": true,
"auth": true,
"signature": true,
"bearer": true,
"apikey": true,
}
// smellsLikeCredential reports whether a parameter name carries credential
// semantics per the guard wordlist: any single segment in credentialWords,
// or the adjacent segment pair api+key.
func smellsLikeCredential(name string) bool {
segments := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
return r == '_' || r == '-' || r == '.'
})
for i, seg := range segments {
if credentialWords[seg] {
return true
}
if seg == "api" && i+1 < len(segments) && segments[i+1] == "key" {
return true
}
}
return false
}
// unredactedCredentialParams returns the names that smell like credentials
// but are NOT matched by the production redaction pattern — every such name
// would render its value verbatim in a dry-run decision.
func unredactedCredentialParams(names []string) []string {
var findings []string
for _, name := range names {
if smellsLikeCredential(name) && !sensitiveParamName.MatchString(name) {
findings = append(findings, name)
}
}
return findings
}
// The detector itself must bite before the live scan means anything: known
// credential-shaped names that the production pattern misses must be caught,
// and ordinary identifier names must pass.
func TestRedactionGuardDetector_SelfCheck(t *testing.T) {
// Credential-shaped and covered by the production pattern: no finding.
for _, name := range []string{"access_token", "client_secret", "user_password", "session_cookie", "sso_credential"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
t.Errorf("%q is redacted by the production pattern, the guard must not flag it, got %v", name, got)
}
}
// Credential-shaped but NOT covered by the production pattern today: the
// guard must flag these, otherwise it can never catch a real gap.
for _, name := range []string{"api_key", "auth_code", "request_signature", "bearer_value"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 1 {
t.Errorf("%q smells like a credential and is not redacted; the guard must flag it, got %v", name, got)
}
}
// Ordinary identifiers, including the wide-false-positive shapes the
// wordlist is segment-matched to avoid: no finding.
for _, name := range []string{"whiteboard_id", "chat_key", "tokenizer_mode", "author", "meeting_no"} {
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
t.Errorf("%q is an ordinary identifier, the guard must not flag it, got %v", name, got)
}
}
}
// Every declared parameter of every compiled EventKey either carries no
// credential semantics or is caught by the production redaction pattern.
func TestRedactionGuard_CatalogParamsHaveNoUnredactedCredentials(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
var names []string
for _, entry := range snap.Entries() {
desc := entry.Descriptor()
for _, p := range desc.Params {
names = append(names, desc.Key+": "+p.Name)
if findings := unredactedCredentialParams([]string{p.Name}); len(findings) != 0 {
t.Errorf("EventKey %s declares param %q which smells like a credential but is not matched by the redaction pattern; rename the param or extend sensitiveParamName deliberately", desc.Key, p.Name)
}
}
}
// A scan that visited no parameters proves nothing.
if len(names) == 0 {
t.Fatal("the compiled catalog declares no parameters at all; the guard scanned nothing")
}
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"reflect"
"strings"
"testing"
)
// renderedDeclarationFields lists every JSON field the list/schema commands
// are allowed to expose, each with the reason it belongs to the public
// contract. Golden files pin today's bytes; this gate protects tomorrow: a
// field added to the rendered structs (promoted through the embedded
// definition or nested anywhere under it) must either appear here
// deliberately or be tagged `json:"-"`. The set is flat — an entry admits its
// rendered name at any nesting level, which is the same latitude
// encoding/json gives a name.
var renderedDeclarationFields = map[string]string{
"key": "stable identifier agents subscribe by",
"domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical",
"display_name": "human-readable name for pickers",
"description": "what the event means (KeyDefinition) / what the parameter does (ParamDef)",
"event_type": "upstream event type behind this key",
"subscription_type": "which console ledger the precheck reads",
"params": "declared consume parameters",
"schema": "declared schema source (native/custom markers)",
"scopes": "OAuth scopes required to consume",
"auth_types": "identities the key accepts",
"required_console_events": "console switches that must be enabled",
"buffer_size": "delivery buffer size after normalization",
"workers": "worker count after normalization",
"single_consumer": "whether a second consumer is rejected",
"resolved_output_schema": "fully resolved JSON schema of stdout events",
"jq_root_path": "schema command only: jq root for consuming stdout",
// Nested under params (ParamDef): everything an agent needs to pass the
// parameter correctly.
"name": "parameter name as passed via --param",
"type": "parameter value type (string/enum/multi/bool/int)",
"required": "whether the parameter must be provided",
"default": "value applied when the parameter is omitted",
"values": "allowed values for enum/multi parameters",
"subscription_key": "whether the parameter is part of the subscription identity",
// Nested under params.values (ParamValue).
"value": "one allowed parameter value",
"desc": "what choosing this value means",
// Nested under schema (SchemaDef / SchemaSpec): declaration markers only;
// the resolved schema is the sibling resolved_output_schema.
"native": "marker for keys delivering the raw V2 envelope",
"custom": "marker for keys delivering processed output",
"field_overrides": "per-field annotations overriding the reflected schema",
"raw": "raw declared schema bytes; empty for reflected types",
// Nested under schema.field_overrides (schemas.FieldMeta). The type has
// no json tags, so encoding/json renders the Go field names — pinned
// as-is because retagging them would change the public bytes.
"Description": "override for the field's schema description",
"Enum": "override for the field's allowed values",
"Kind": "override rendered as the field's schema format",
}
// TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes,
// following embedded struct promotion and recursing into every named type
// reachable through the rendered fields, and fails on any exported member
// that is neither allowlisted nor explicitly excluded from JSON.
func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) {
emitted := map[string]bool{}
for _, typ := range []reflect.Type{
reflect.TypeFor[listRow](),
reflect.TypeFor[schemaPayload](),
} {
walkRenderedFields(t, typ, emitted, map[reflect.Type]bool{})
}
if len(emitted) == 0 {
t.Fatal("no rendered fields were visited; the gate scanned nothing")
}
// The embedded definition is where leaks would hide: prove promotion was
// actually followed by requiring fields that only exist on it. The nested
// sentinels prove each recursion path is really taken: subscription_key
// (slice-of-struct: ParamDef), desc (slice inside a nested struct:
// ParamValue), raw (pointer-to-struct: SchemaSpec), Enum (map value:
// FieldMeta, rendered under its Go name because the type is untagged).
for _, sentinel := range []string{
"key", "event_type", "resolved_output_schema",
"subscription_key", "desc", "raw", "Enum",
} {
if !emitted[sentinel] {
t.Fatalf("field %q was not visited; the walker no longer reaches every rendered shape", sentinel)
}
}
for name := range renderedDeclarationFields {
if !emitted[name] {
t.Errorf("allowlist entry %q is stale: no rendered struct emits it", name)
}
}
}
// walkRenderedFields records every JSON field name typ can render: embedded
// structs promote into the parent object, and any struct reachable through a
// field's type — behind pointers, slice/array elements, or map values — is
// walked in turn, so a field added to a nested type like ParamDef cannot
// escape the gate. visited breaks cycles; a type already recorded in this
// walk contributes nothing new.
func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool, visited map[reflect.Type]bool) {
t.Helper()
typ = nestedStructType(typ)
if typ == nil || visited[typ] {
return
}
visited[typ] = true
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
tag := field.Tag.Get("json")
if tag == "-" {
continue
}
if field.Anonymous && tag == "" {
if ft := nestedStructType(field.Type); ft != nil {
// Embedded struct without a tag: fields promote into the
// parent JSON object.
walkRenderedFields(t, ft, emitted, visited)
continue
}
}
name, _, _ := strings.Cut(tag, ",")
if name == "" {
// encoding/json renders an untagged exported field under its Go
// name (schemas.FieldMeta does this today); the rendered name is
// what the contract governs, so it is what must be declared.
name = field.Name
}
if _, ok := renderedDeclarationFields[name]; !ok {
t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it with json:\"-\"", typ.Name(), field.Name, name)
}
emitted[name] = true
walkRenderedFields(t, field.Type, emitted, visited)
}
}
// nestedStructType unwraps pointers, slice/array elements, and map values
// until it reaches the struct that would render as a JSON object; nil means
// the type renders as a leaf (scalar, string, raw bytes) and holds no fields
// to govern.
func nestedStructType(typ reflect.Type) reflect.Type {
for {
switch typ.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
typ = typ.Elem()
case reflect.Struct:
return typ
default:
return nil
}
}
}

View File

@@ -11,75 +11,13 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/output"
)
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil, nil, nil
}
base, err := renderSpec(spec)
if err != nil {
return nil, nil, err
}
if base == nil {
return nil, nil, nil
}
if isNative {
base = schemas.WrapV2Envelope(base)
}
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
}
return out, orphans, nil
}
return base, nil, nil
}
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
if s.Type != nil {
return schemas.FromType(s.Type), nil
}
if len(s.Raw) > 0 {
buf := make(json.RawMessage, len(s.Raw))
copy(buf, s.Raw)
return buf, nil
}
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "schema <EventKey>",
@@ -87,7 +25,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSchema(f, args[0], asJSON)
return runSchema(f, snap, args[0], asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
@@ -95,14 +33,15 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
return cmd
}
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
def, ok := eventlib.Lookup(key)
func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bool) error {
entry, ok := snap.Resolve(key)
if !ok {
return unknownEventKeyErr(key)
return unknownEventKeyErr(snap, key)
}
def := entry.Definition()
if asJSON {
return writeSchemaJSON(f, def)
return writeSchemaJSON(f, entry)
}
out := f.IOStreams.Out
@@ -170,10 +109,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
}
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
resolved := entry.Output().SchemaJSON
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")
printIndentedJSON(out, resolved)
@@ -202,30 +138,22 @@ func printIndentedJSON(out io.Writer, raw json.RawMessage) {
fmt.Fprintf(out, " %s\n", string(formatted))
}
// schemaPayload is the JSON shape of `event schema --json`. It is a named
// type (not a function-local literal) so the render contract test can walk
// its fields and reject accidental additions to the public output.
type schemaPayload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
type payload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
var jqRootPath string
if resolved != nil {
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
_, isNative := pickSpec(def.Schema)
jqRootPath = "."
if isNative {
jqRootPath = ".event"
}
}
output.PrintJson(f.IOStreams.Out, payload{
KeyDefinition: def,
ResolvedSchema: resolved,
JQRootPath: jqRootPath,
func writeSchemaJSON(f *cmdutil.Factory, entry *catalog.Entry) error {
contract := entry.Output()
output.PrintJson(f.IOStreams.Out, schemaPayload{
KeyDefinition: entry.Definition(),
ResolvedSchema: contract.SchemaJSON,
JQRootPath: contract.JQRootPath,
})
return nil
}

View File

@@ -10,15 +10,27 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
_ "github.com/larksuite/cli/events"
)
// compileTestSnapshot compiles synthetic declarations into a snapshot using
// the same strategy set the production wiring provides.
func compileTestSnapshot(t *testing.T, defs ...eventlib.KeyDefinition) *catalog.Snapshot {
t.Helper()
snap, err := catalog.Compile(defs, catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile test catalog: %v", err)
}
return snap
}
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
@@ -45,7 +57,7 @@ type approvalSchemaJSONProperty struct {
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -65,7 +77,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
if err := runSchema(f, compileCatalog(), "im.message.message_read_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -85,7 +97,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runSchema(f, "im.message.recieve_v1", false)
err := runSchema(f, compileCatalog(), "im.message.recieve_v1", false)
if err == nil {
t.Fatal("expected error for unknown key")
}
@@ -101,7 +113,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
func TestRunSchema_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -122,7 +134,7 @@ func TestRunSchema_JSONOutput(t *testing.T) {
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -156,7 +168,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
if err := runSchema(f, compileCatalog(), "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -195,7 +207,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
if err := runSchema(f, compileCatalog(), tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -243,7 +255,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
if err := runSchema(f, compileCatalog(), key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -276,9 +288,8 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
@@ -289,7 +300,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
if err := runSchema(f, snap, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -325,9 +336,8 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
@@ -335,7 +345,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
if err := runSchema(f, snap, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
@@ -349,12 +359,13 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
type out struct {
SenderID string `json:"sender_id"`
}
eventlib.RegisterKey(eventlib.KeyDefinition{
// A compile that succeeds proves the overlay left no orphan pointers; the
// entry's output contract carries the resolved schema.
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Schema: eventlib.SchemaDef{
@@ -367,13 +378,12 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
return nil, nil
},
})
def, _ := eventlib.Lookup(syntheticKey)
resolved, orphans, err := resolveSchemaJSON(def)
if err != nil || len(orphans) != 0 {
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
entry, ok := snap.Resolve(syntheticKey)
if !ok {
t.Fatalf("snap.Resolve(%q) should succeed", syntheticKey)
}
var parsed map[string]interface{}
if err := json.Unmarshal(resolved, &parsed); err != nil {
if err := json.Unmarshal(entry.Output().SchemaJSON, &parsed); err != nil {
t.Fatal(err)
}
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
@@ -382,37 +392,35 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
func TestCompile_EmptySpecIsRejected(t *testing.T) {
_, err := catalog.Compile([]eventlib.KeyDefinition{{
Key: "synthetic.empty.spec",
EventType: "synthetic.empty.spec",
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{}},
}}, catalog.StrategyRefs{catalog.StrategyNone})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
if !strings.Contains(err.Error(), "exactly one of Type or Raw") {
t.Errorf("error should reject the empty spec, got: %v", err)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) {
_, err := catalog.Compile([]eventlib.KeyDefinition{{
Key: "synthetic.invalid.base",
EventType: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
}}, catalog.StrategyRefs{catalog.StrategyNone})
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
// Garbage raw bytes are rejected by the spec check itself, before the
// overlay machinery would even try to parse them.
if !strings.Contains(err.Error(), "is not a JSON object") {
t.Errorf("error should reject the unparsable base schema, got: %v", err)
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
eventlib "github.com/larksuite/cli/internal/event"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/event/catalog"
)
// consumeStrategies is the executable strategy set for this binary. The same
// registry is handed to catalog compilation, so a reference the compiler
// accepted is guaranteed to resolve here.
var consumeStrategies = appconsume.DefaultRegistry()
type identityResolverFunc func(ctx context.Context, entry *catalog.Entry) (string, error)
func (f identityResolverFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) {
return f(ctx, entry)
}
type preflightReaderFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error)
func (f preflightReaderFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) {
return f(ctx, entry, identity)
}
type streamRunnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error
func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error {
return f(ctx, prepare)
}
// readPreconditions classifies the existing read-only preflight checks into
// named preconditions. Weak dependencies that could not answer stay visible
// as "unknown" instead of silently passing; a failed check carries the exact
// error a real run returns, so refusal is identical on both paths.
func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenErr error) []appconsume.Precondition {
credentials := appconsume.Precondition{Name: "credentials_available", Status: appconsume.PreconditionOK}
if tokenErr != nil {
credentials.Status = appconsume.PreconditionBlocked
credentials.Detail = tokenErr.Error()
credentials.BlockErr = tokenErr
}
console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK}
switch {
case len(pf.keyDef.RequiredConsoleEvents) == 0:
// nothing to verify
case pf.keyDef.SubscriptionType == eventlib.SubTypeCallback && pf.subscribedCallbacks == nil,
pf.keyDef.SubscriptionType != eventlib.SubTypeCallback && pf.appVer == nil:
console.Status = appconsume.PreconditionUnknown
if appVerErr != nil {
console.Detail = describeAppMetaErr(appVerErr)
} else {
console.Detail = "console ledger unavailable"
}
default:
if err := preflightEventTypes(pf); err != nil {
console.Status = appconsume.PreconditionBlocked
console.Detail = err.Error()
console.BlockErr = err
}
}
scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK}
checked, err := preflightScopes(ctx, pf)
switch {
case err != nil:
scopes.Status = appconsume.PreconditionBlocked
scopes.Detail = err.Error()
scopes.BlockErr = err
case !checked:
// The scope ledger could not be read (no published version for bots,
// no resolvable token for users). Saying "ok" here would dress up
// "nobody looked" as "it was verified".
scopes.Status = appconsume.PreconditionUnknown
scopes.Detail = "granted scopes could not be read for this identity"
}
return []appconsume.Precondition{credentials, console, scopes}
}

View File

@@ -14,10 +14,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/output"
)

View File

@@ -11,8 +11,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
)
type fakeScanner struct {

View File

@@ -13,9 +13,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/output"
)

View File

@@ -9,7 +9,7 @@ import (
"sort"
"testing"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
)
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {

View File

@@ -13,7 +13,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
)
type mockTransport struct {

View File

@@ -9,14 +9,14 @@ import (
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
func suggestEventKeys(input string) []string {
func suggestEventKeys(snap *catalog.Snapshot, input string) []string {
type match struct {
key string
dist int
@@ -24,13 +24,13 @@ func suggestEventKeys(input string) []string {
var hits []match
threshold := max(2, len(input)/5)
for _, def := range eventlib.ListAll() {
if strings.Contains(def.Key, input) {
hits = append(hits, match{def.Key, 0})
for _, key := range snap.Keys() {
if strings.Contains(key, input) {
hits = append(hits, match{key, 0})
continue
}
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
if d := suggest.Levenshtein(input, key); d <= threshold {
hits = append(hits, match{key, d})
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
@@ -59,9 +59,9 @@ func formatSuggestions(keys []string) string {
}
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
func unknownEventKeyErr(key string) error {
func unknownEventKeyErr(snap *catalog.Snapshot, key string) error {
msg := fmt.Sprintf("unknown EventKey: %s", key)
if guesses := suggestEventKeys(key); len(guesses) > 0 {
if guesses := suggestEventKeys(snap, key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).

View File

@@ -6,11 +6,10 @@ package event
import (
"strings"
"testing"
_ "github.com/larksuite/cli/events"
)
func TestSuggestEventKeys(t *testing.T) {
snap := compileCatalog()
cases := []struct {
name string
input string
@@ -41,7 +40,7 @@ func TestSuggestEventKeys(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := suggestEventKeys(tc.input)
got := suggestEventKeys(snap, tc.input)
if tc.wantEmpty {
if len(got) != 0 {
t.Errorf("expected empty slice, got %v", got)
@@ -98,7 +97,7 @@ func TestFormatSuggestions(t *testing.T) {
}
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
err := unknownEventKeyErr("im.message.recieve_v1")
err := unknownEventKeyErr(compileCatalog(), "im.message.recieve_v1")
if err == nil {
t.Fatal("expected error")
}
@@ -115,7 +114,7 @@ func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
}
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
err := unknownEventKeyErr(compileCatalog(), "xyzzy_no_such_event_key_at_all")
if err == nil {
t.Fatal("expected error")
}

3023
cmd/event/testdata/golden/list_json.golden vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
KEY AUTH PARAMS DESCRIPTION
── application ──
application.bot.menu_v6 bot 0 Triggered when a user clicks a custom bot menu item whose action is configured as a push event.
── approval ──
approval.instance.status_changed_v4 user 1 Triggered after an approval instance status becomes visible to the requester or approval participants
approval.task.status_changed_v4 user 1 Triggered after an approval task status becomes visible to the requester or task approver
── board ──
board.whiteboard.updated_v1 user|bot 1 Pushed when the whiteboard content is updated.
── card ──
card.action.trigger bot 0 Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
── im ──
im.chat.disbanded_v1 bot 0 Triggered after a chat is disbanded
im.chat.member.bot.added_v1 bot 0 Triggered when the bot is added to a chat
im.chat.member.bot.deleted_v1 bot 0 Triggered after the bot is removed from a chat
im.chat.member.user.added_v1 bot 0 Triggered when a new user joins a chat (including topic chats)
im.chat.member.user.deleted_v1 bot 0 Triggered when a user leaves or is removed from a chat
im.chat.member.user.withdrawn_v1 bot 0 Triggered after a pending user invite is withdrawn
im.chat.updated_v1 bot 0 Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
im.message.message_read_v1 bot 0 Triggered after a user reads a P2P message sent by the bot
im.message.reaction.created_v1 bot 0 Triggered when a reaction is added to a message
im.message.reaction.deleted_v1 bot 0 Triggered when a reaction is removed from a message
im.message.receive_v1 bot 0 Receive IM messages
── minutes ──
minutes.minute.generated_v1 user 0 Triggered when a minute has been generated
── task ──
task.task.update_user_access_v2 user|bot 0 Triggered when tasks visible to the current user or app are created, deleted, or updated
── vc ──
vc.meeting.participant_meeting_ended_v1 user 0 Triggered when a meeting the current user participates in has ended
vc.meeting.participant_meeting_joined_v1 user 0 Triggered when the current user joins a meeting
vc.meeting.participant_meeting_started_v1 user 0 Triggered when a meeting the current user participates in has started
vc.note.generated_v1 user 0 Triggered when a note has been generated
vc.recording.recording_ended_v1 user 0 Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.
vc.recording.recording_started_v1 user 0 Triggered when a recording_bean recording starts; only generated when connected to Feishu software.
vc.recording.recording_transcript_generated_v1 user 0 Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.

View File

@@ -0,0 +1,127 @@
{
"key": "board.whiteboard.updated_v1",
"display_name": "Whiteboard updated",
"description": "Pushed when the whiteboard content is updated.",
"event_type": "board.whiteboard.updated_v1",
"subscription_type": "event",
"params": [
{
"name": "whiteboard_id",
"type": "string",
"required": true,
"description": "Whiteboard id to subscribe; subscription is per-whiteboard.",
"subscription_key": true
}
],
"schema": {
"native": {},
"field_overrides": {
"/event/operator_ids/*/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/operator_ids/*/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/operator_ids/*/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/whiteboard_id": {
"Description": "whiteboard id to subscribe",
"Enum": null,
"Kind": "whiteboard_id"
}
}
},
"scopes": [
"board:whiteboard:node:read"
],
"auth_types": [
"user",
"bot"
],
"required_console_events": [
"board.whiteboard.updated_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"operator_ids": {
"items": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"whiteboard_id": {
"description": "whiteboard id to subscribe",
"format": "whiteboard_id",
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
},
"jq_root_path": ".event"
}

View File

@@ -0,0 +1,89 @@
Key: board.whiteboard.updated_v1
Description: Pushed when the whiteboard content is updated.
Event: board.whiteboard.updated_v1
Pre-consume: yes
Required Scopes:
- board:whiteboard:node:read
Required Console Events (must be enabled in developer console):
- board.whiteboard.updated_v1
Parameters:
NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION
whiteboard_id string yes yes - Whiteboard id to subscribe; subscription is per-whiteboard.
Output Schema:
{
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"operator_ids": {
"items": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"whiteboard_id": {
"description": "whiteboard id to subscribe",
"format": "whiteboard_id",
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
}

View File

@@ -0,0 +1,104 @@
{
"key": "card.action.trigger",
"display_name": "Card action",
"description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
"event_type": "card.action.trigger",
"subscription_type": "callback",
"schema": {
"custom": {}
},
"scopes": [
"im:message:readonly"
],
"auth_types": [
"bot"
],
"required_console_events": [
"card.action.trigger"
],
"buffer_size": 100,
"workers": 1,
"single_consumer": true,
"resolved_output_schema": {
"type": "object",
"properties": {
"action_name": {
"type": "string",
"description": "Element name attribute"
},
"action_tag": {
"type": "string",
"description": "Triggered element type: button/select_static/input/checker/etc"
},
"action_value": {
"type": "string",
"description": "Developer-defined action value as JSON string"
},
"card_content": {
"type": "string",
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
},
"chat_id": {
"type": "string",
"description": "Chat ID",
"format": "chat_id"
},
"checked": {
"type": "boolean",
"description": "Checkbox state (for checkbox elements)"
},
"event_id": {
"type": "string",
"description": "Globally unique event ID"
},
"form_value": {
"type": "string",
"description": "Form submission values as JSON string (only on form submit)"
},
"host": {
"type": "string",
"description": "Host type: im_message / im_top_notice"
},
"input_value": {
"type": "string",
"description": "Input field value (only for input elements)"
},
"message_id": {
"type": "string",
"description": "Message ID of the card",
"format": "message_id"
},
"operator_id": {
"type": "string",
"description": "Operator open_id",
"format": "open_id"
},
"option": {
"type": "string",
"description": "Selected option value (for single-select dropdown)"
},
"options": {
"type": "string",
"description": "Selected options, comma-separated (for multi-select)"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string)",
"format": "timestamp_ms"
},
"timezone": {
"type": "string",
"description": "User timezone for date/time picker interactions"
},
"token": {
"type": "string",
"description": "Token for delay card update (valid 30 min, max 2 updates)"
},
"type": {
"type": "string",
"description": "Event type; always card.action.trigger"
}
}
},
"jq_root_path": "."
}

View File

@@ -0,0 +1,92 @@
Key: card.action.trigger
Description: Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
Event: card.action.trigger
Required Scopes:
- im:message:readonly
Required Console Events (must be enabled in developer console):
- card.action.trigger
Output Schema:
{
"type": "object",
"properties": {
"action_name": {
"type": "string",
"description": "Element name attribute"
},
"action_tag": {
"type": "string",
"description": "Triggered element type: button/select_static/input/checker/etc"
},
"action_value": {
"type": "string",
"description": "Developer-defined action value as JSON string"
},
"card_content": {
"type": "string",
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
},
"chat_id": {
"type": "string",
"description": "Chat ID",
"format": "chat_id"
},
"checked": {
"type": "boolean",
"description": "Checkbox state (for checkbox elements)"
},
"event_id": {
"type": "string",
"description": "Globally unique event ID"
},
"form_value": {
"type": "string",
"description": "Form submission values as JSON string (only on form submit)"
},
"host": {
"type": "string",
"description": "Host type: im_message / im_top_notice"
},
"input_value": {
"type": "string",
"description": "Input field value (only for input elements)"
},
"message_id": {
"type": "string",
"description": "Message ID of the card",
"format": "message_id"
},
"operator_id": {
"type": "string",
"description": "Operator open_id",
"format": "open_id"
},
"option": {
"type": "string",
"description": "Selected option value (for single-select dropdown)"
},
"options": {
"type": "string",
"description": "Selected options, comma-separated (for multi-select)"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string)",
"format": "timestamp_ms"
},
"timezone": {
"type": "string",
"description": "User timezone for date/time picker interactions"
},
"token": {
"type": "string",
"description": "Token for delay card update (valid 30 min, max 2 updates)"
},
"type": {
"type": "string",
"description": "Event type; always card.action.trigger"
}
}
}

View File

@@ -0,0 +1,430 @@
{
"key": "im.chat.updated_v1",
"display_name": "Chat updated",
"description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
"event_type": "im.chat.updated_v1",
"subscription_type": "event",
"schema": {
"native": {},
"field_overrides": {
"/event/after_change/owner_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/after_change/owner_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/after_change/owner_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/before_change/owner_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/before_change/owner_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/before_change/owner_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/chat_id": {
"Description": "",
"Enum": null,
"Kind": "chat_id"
},
"/event/moderator_list/added_member_list/*/user_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/moderator_list/added_member_list/*/user_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/moderator_list/added_member_list/*/user_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/moderator_list/removed_member_list/*/user_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/moderator_list/removed_member_list/*/user_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/moderator_list/removed_member_list/*/user_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
},
"/event/operator_id/open_id": {
"Description": "",
"Enum": null,
"Kind": "open_id"
},
"/event/operator_id/union_id": {
"Description": "",
"Enum": null,
"Kind": "union_id"
},
"/event/operator_id/user_id": {
"Description": "",
"Enum": null,
"Kind": "user_id"
}
}
},
"scopes": [
"im:chat:read"
],
"auth_types": [
"bot"
],
"required_console_events": [
"im.chat.updated_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"after_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"before_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"chat_id": {
"format": "chat_id",
"type": "string"
},
"external": {
"type": "boolean"
},
"moderator_list": {
"properties": {
"added_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
},
"removed_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
},
"operator_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"operator_tenant_key": {
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
},
"jq_root_path": ".event"
}

View File

@@ -0,0 +1,337 @@
Key: im.chat.updated_v1
Description: Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
Event: im.chat.updated_v1
Required Scopes:
- im:chat:read
Required Console Events (must be enabled in developer console):
- im.chat.updated_v1
Output Schema:
{
"description": "飞书事件",
"properties": {
"event": {
"properties": {
"after_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"before_change": {
"properties": {
"add_member_permission": {
"type": "string"
},
"at_all_permission": {
"type": "string"
},
"avatar": {
"type": "string"
},
"description": {
"type": "string"
},
"edit_permission": {
"type": "string"
},
"group_message_type": {
"type": "string"
},
"i18n_names": {
"properties": {
"en_us": {
"type": "string"
},
"ja_jp": {
"type": "string"
},
"zh_cn": {
"type": "string"
}
},
"type": "object"
},
"join_message_visibility": {
"type": "string"
},
"labels": {
"items": {
"type": "string"
},
"type": "array"
},
"leave_message_visibility": {
"type": "string"
},
"membership_approval": {
"type": "string"
},
"moderation_permission": {
"type": "string"
},
"name": {
"type": "string"
},
"owner_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"restricted_mode_setting": {
"properties": {
"download_has_permission_setting": {
"type": "string"
},
"message_has_permission_setting": {
"type": "string"
},
"screenshot_has_permission_setting": {
"type": "string"
},
"status": {
"type": "boolean"
}
},
"type": "object"
},
"share_card_permission": {
"type": "string"
}
},
"type": "object"
},
"chat_id": {
"format": "chat_id",
"type": "string"
},
"external": {
"type": "boolean"
},
"moderator_list": {
"properties": {
"added_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
},
"removed_member_list": {
"items": {
"properties": {
"tenant_key": {
"type": "string"
},
"user_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
},
"operator_id": {
"properties": {
"open_id": {
"format": "open_id",
"type": "string"
},
"union_id": {
"format": "union_id",
"type": "string"
},
"user_id": {
"format": "user_id",
"type": "string"
}
},
"type": "object"
},
"operator_tenant_key": {
"type": "string"
}
},
"type": "object"
},
"header": {
"description": "事件头,所有事件结构一致",
"properties": {
"app_id": {
"description": "接收事件的应用 ID",
"type": "string"
},
"create_time": {
"description": "事件创建时间,毫秒时间戳字符串",
"type": "string"
},
"event_id": {
"description": "事件唯一 ID",
"type": "string"
},
"event_type": {
"description": "事件类型,用于路由",
"type": "string"
},
"tenant_key": {
"description": "租户唯一标识",
"type": "string"
},
"token": {
"description": "回调校验 token",
"type": "string"
}
},
"type": "object"
},
"schema": {
"description": "飞书事件协议版本",
"enum": [
"2.0"
],
"type": "string"
}
},
"type": "object"
}

View File

@@ -0,0 +1,130 @@
{
"key": "im.message.receive_v1",
"display_name": "Receive message",
"description": "Receive IM messages",
"event_type": "im.message.receive_v1",
"subscription_type": "event",
"schema": {
"custom": {}
},
"scopes": [
"im:message.p2p_msg:readonly"
],
"auth_types": [
"bot"
],
"required_console_events": [
"im.message.receive_v1"
],
"buffer_size": 100,
"workers": 1,
"resolved_output_schema": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"description": "Chat/conversation ID; prefixed with oc_",
"format": "chat_id"
},
"chat_type": {
"type": "string",
"description": "Conversation type",
"enum": [
"p2p",
"group"
]
},
"content": {
"type": "string",
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
},
"create_time": {
"type": "string",
"description": "Message creation time (ms timestamp string)",
"format": "timestamp_ms"
},
"event_id": {
"type": "string",
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
},
"id": {
"type": "string",
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
"format": "message_id"
},
"mentions": {
"type": "array",
"description": "Compact mentions aligned with im +messages-mget",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Mentioned user open_id; prefixed with ou_",
"format": "open_id"
},
"key": {
"type": "string",
"description": "Mention placeholder key, for example @_user_1"
},
"name": {
"type": "string",
"description": "Mentioned display name"
}
}
}
},
"message_id": {
"type": "string",
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
"format": "message_id"
},
"message_type": {
"type": "string",
"description": "Message type"
},
"reply_to": {
"type": "string",
"description": "Parent message ID of the direct reply context, when present",
"format": "message_id"
},
"root_id": {
"type": "string",
"description": "Root message ID of the reply/thread context, when present",
"format": "message_id"
},
"sender_id": {
"type": "string",
"description": "Sender open_id; prefixed with ou_",
"format": "open_id"
},
"sender_type": {
"type": "string",
"description": "Sender type",
"enum": [
"user",
"bot"
]
},
"thread_id": {
"type": "string",
"description": "Thread ID, when present"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
"format": "timestamp_ms"
},
"type": {
"type": "string",
"description": "Event type; always im.message.receive_v1"
},
"update_time": {
"type": "string",
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
"format": "timestamp_ms"
}
}
},
"jq_root_path": "."
}

View File

@@ -0,0 +1,119 @@
Key: im.message.receive_v1
Description: Receive IM messages
Event: im.message.receive_v1
Required Scopes:
- im:message.p2p_msg:readonly
Required Console Events (must be enabled in developer console):
- im.message.receive_v1
Output Schema:
{
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"description": "Chat/conversation ID; prefixed with oc_",
"format": "chat_id"
},
"chat_type": {
"type": "string",
"description": "Conversation type",
"enum": [
"p2p",
"group"
]
},
"content": {
"type": "string",
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
},
"create_time": {
"type": "string",
"description": "Message creation time (ms timestamp string)",
"format": "timestamp_ms"
},
"event_id": {
"type": "string",
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
},
"id": {
"type": "string",
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
"format": "message_id"
},
"mentions": {
"type": "array",
"description": "Compact mentions aligned with im +messages-mget",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Mentioned user open_id; prefixed with ou_",
"format": "open_id"
},
"key": {
"type": "string",
"description": "Mention placeholder key, for example @_user_1"
},
"name": {
"type": "string",
"description": "Mentioned display name"
}
}
}
},
"message_id": {
"type": "string",
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
"format": "message_id"
},
"message_type": {
"type": "string",
"description": "Message type"
},
"reply_to": {
"type": "string",
"description": "Parent message ID of the direct reply context, when present",
"format": "message_id"
},
"root_id": {
"type": "string",
"description": "Root message ID of the reply/thread context, when present",
"format": "message_id"
},
"sender_id": {
"type": "string",
"description": "Sender open_id; prefixed with ou_",
"format": "open_id"
},
"sender_type": {
"type": "string",
"description": "Sender type",
"enum": [
"user",
"bot"
]
},
"thread_id": {
"type": "string",
"description": "Thread ID, when present"
},
"timestamp": {
"type": "string",
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
"format": "timestamp_ms"
},
"type": {
"type": "string",
"description": "Event type; always im.message.receive_v1"
},
"update_time": {
"type": "string",
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
"format": "timestamp_ms"
}
}
}

25
cmd/event/wiring.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// compileCatalog is the event command tree's single assembly point: it turns
// the aggregated domain declarations into the immutable snapshot every
// subcommand reads. A compile failure is a defect in declarations built into
// this binary — there is nothing to recover at runtime, so it panics.
func compileCatalog() *catalog.Snapshot {
// The strategy registry that validates references is the same one that
// executes them, so "compiled" implies "resolvable at run time".
snap, err := catalog.Compile(events.All(), consumeStrategies)
if err != nil {
panic(fmt.Sprintf("event catalog failed to compile: %v", err))
}
return snap
}

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

@@ -1,7 +1,9 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
// Package events aggregates the domain EventKey declarations. All returns
// them explicitly — whoever needs a catalog compiles one; nothing registers
// itself through import side effects.
package events
import (
@@ -12,12 +14,14 @@ import (
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// All returns every domain's declarations, ready for catalog.Compile.
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
func All() []catalog.KeyDefinition {
var all []catalog.KeyDefinition
for _, keys := range [][]catalog.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
@@ -25,10 +29,8 @@ func init() {
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
} {
all = append(all, keys...)
}
for _, keys := range all {
for _, k := range keys {
event.RegisterKey(k)
}
}
return all
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
@@ -29,13 +30,6 @@ type BotMenuOutput struct {
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
@@ -50,11 +44,11 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
timestamp := raw.SourceTime
if timestamp == "" {
timestamp = menuTimestamp
}
@@ -62,10 +56,10 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
EventID: raw.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
AppID: raw.AppID,
TenantKey: raw.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,

View File

@@ -11,6 +11,8 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/processing"
)
func TestKeysBotMenuMetadata(t *testing.T) {
@@ -51,14 +53,15 @@ func TestKeysBotMenuMetadata(t *testing.T) {
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
}
}
@@ -199,14 +202,42 @@ func TestProcessBotMenuMalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id, create_time,
// app_id, and tenant_key from the RawEvent, which the consume pipeline fills
// from the envelope header before dispatch; tests that hand-build a RawEvent
// must mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
raw.AppID = envelope.Header.AppID
raw.TenantKey = envelope.Header.TenantKey
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
@@ -215,6 +246,7 @@ func runBotMenu(t *testing.T, payload string) BotMenuOutput {
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)

View File

@@ -13,23 +13,13 @@ import (
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
func approvalSubscriptionPreConsume(eventType, subscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err

View File

@@ -10,6 +10,7 @@ import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
const (
@@ -40,12 +41,9 @@ func Keys() []event.KeyDefinition {
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, pathApprovalInstancesSubscription),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
@@ -60,12 +58,9 @@ func Keys() []event.KeyDefinition {
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
@@ -99,11 +94,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient,
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
@@ -114,13 +104,13 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient,
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
@@ -128,9 +118,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
@@ -139,11 +126,6 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
@@ -156,13 +138,13 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
@@ -172,8 +154,5 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -14,6 +14,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/event/schemas"
)
@@ -255,10 +257,7 @@ func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
pc := approvalSubscriptionPreConsume(tc.eventType, tc.subscribePath)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
@@ -297,9 +296,7 @@ func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wan
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "")
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
@@ -312,9 +309,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) {
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "")
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
@@ -338,10 +333,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
pc := approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription)
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
@@ -553,7 +545,7 @@ func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
func TestProcessApprovalStatusChangedMalformedPayloadDrop(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
@@ -569,11 +561,11 @@ func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
})
}
@@ -599,6 +591,30 @@ func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
@@ -606,6 +622,7 @@ func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInst
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
@@ -624,6 +641,7 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
@@ -636,17 +654,16 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
}
}
}

356
events/arch_test.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Architecture gates for the events declaration layer.
//
// events/<domain> packages are declarations: EventKeys, payload shapes, and
// processing hooks. Two kinds of rot would quietly destroy that role:
//
// 1. Importing command wiring, a transport host, or a concrete adapter turns
// declarations into another place where process and transport concerns
// accumulate, and drags the whole adapter tree into every binary that
// only wanted the catalog.
// 2. Re-parsing the envelope header inside a domain duplicates the kernel's
// single header decode; the copies then drift apart the day the envelope
// evolves.
//
// These tests turn both into build breaks.
package events_test
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"testing"
)
const (
archModulePath = "github.com/larksuite/cli"
archAdapterImportPrefix = archModulePath + "/internal/event/adapter"
)
// archProductionGoFiles returns every non-test .go file under root,
// skipping testdata directories. Paths are relative to root.
func archProductionGoFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "testdata" {
return fs.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
sort.Strings(files)
return files
}
// archForbiddenDomainImport reports why importPath is banned in events/, if
// it is. Domains may use the kernel (internal/event, model, catalog,
// processing, ...); they must never see the layers that host or transport
// them.
func archForbiddenDomainImport(importPath string) (reason string, banned bool) {
switch importPath {
case "github.com/spf13/cobra":
return "CLI framework; command wiring lives in cmd, a declaration that needs cobra has stopped being a declaration", true
case archModulePath + "/internal/event/bus":
return "bus is a host process; a domain importing its host inverts the dependency direction", true
case archModulePath + "/internal/event/consume":
return "consume is a host process; a domain importing its host inverts the dependency direction", true
}
if importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/") {
return "concrete adapter; domains must stay transport-agnostic so any host can serve them", true
}
return "", false
}
// TestArchEventsImportRedline fails when any production file under events/
// imports command wiring, an event host, or a concrete adapter. It keeps the
// declaration layer linkable everywhere without pulling in transports.
func TestArchEventsImportRedline(t *testing.T) {
files := archProductionGoFiles(t, ".")
if len(files) == 0 {
t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run")
}
fset := token.NewFileSet()
for _, file := range files {
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
for _, imp := range f.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
t.Fatalf("unquote import in %s: %v", file, err)
}
if reason, banned := archForbiddenDomainImport(path); banned {
t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason)
}
}
}
}
// envelopeHeaderTags are the metadata fields the kernel decodes exactly once
// from the envelope header. A domain that re-declares any of them inside a
// json:"header" block is re-parsing the envelope instead of consuming the
// kernel's decode — the duplicate drifts silently when the envelope changes.
var envelopeHeaderTags = map[string]bool{
"event_id": true,
"event_type": true,
"create_time": true,
"app_id": true,
"tenant_key": true,
}
// headerReparseBaseline is the ratchet of pinned pre-existing residue, keyed
// by file (relative to events/) with the header metadata tags it re-parses.
// It is empty: every domain consumes the kernel-decoded header, so the gate
// runs at zero tolerance. Never add an entry — new code must read the
// kernel-decoded header instead of unmarshalling the envelope again.
var headerReparseBaseline = map[string][]string{}
type archHeaderReparse struct {
file string // slash path relative to events/
line int
field string // Go field name inside the header block
tag string // offending json tag
}
// archJSONTagName extracts the json name (first comma segment) from a struct
// field tag, or "" when absent.
func archJSONTagName(field *ast.Field) string {
if field.Tag == nil {
return ""
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
return ""
}
name, _, _ := strings.Cut(reflect.StructTag(raw).Get("json"), ",")
return name
}
// archNamedStructIndex maps type names declared in the given files (one
// package) to their struct bodies, so a json:"header" field with a named
// type still resolves.
func archNamedStructIndex(files []*ast.File) map[string]*ast.StructType {
index := make(map[string]*ast.StructType)
for _, f := range files {
for _, decl := range f.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if st, ok := ts.Type.(*ast.StructType); ok {
index[ts.Name.Name] = st
}
}
}
}
return index
}
// archStructBody resolves expr to a struct body: inline struct types,
// pointers to them, and named types declared in the same package.
func archStructBody(expr ast.Expr, named map[string]*ast.StructType) *ast.StructType {
switch v := expr.(type) {
case *ast.StructType:
return v
case *ast.StarExpr:
return archStructBody(v.X, named)
case *ast.Ident:
return named[v.Name]
}
return nil
}
// archFindHeaderReparses flags every field inside a json:"header" struct
// block whose json tag re-declares envelope header metadata. Fields outside
// header blocks are never flagged: a domain body owning its own create_time
// (e.g. a message's own timestamps) is legitimate.
func archFindHeaderReparses(fset *token.FileSet, file *ast.File, relPath string, named map[string]*ast.StructType) []archHeaderReparse {
var found []archHeaderReparse
ast.Inspect(file, func(n ast.Node) bool {
st, ok := n.(*ast.StructType)
if !ok {
return true
}
for _, field := range st.Fields.List {
if archJSONTagName(field) != "header" {
continue
}
body := archStructBody(field.Type, named)
if body == nil {
continue
}
for _, hf := range body.Fields.List {
tag := archJSONTagName(hf)
if !envelopeHeaderTags[tag] {
continue
}
name := "(embedded)"
if len(hf.Names) > 0 {
parts := make([]string, len(hf.Names))
for i, ident := range hf.Names {
parts[i] = ident.Name
}
name = strings.Join(parts, ",")
}
found = append(found, archHeaderReparse{
file: relPath,
line: fset.Position(hf.Pos()).Line,
field: name,
tag: tag,
})
}
}
return true
})
return found
}
// TestArchEventsNoHeaderMetadataReparse fails when a production file under
// events/ declares a json:"header" struct block that re-parses envelope
// header metadata, except for the pinned pre-existing residue in
// headerReparseBaseline (which may only shrink).
func TestArchEventsNoHeaderMetadataReparse(t *testing.T) {
files := archProductionGoFiles(t, ".")
if len(files) == 0 {
t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run")
}
// Parse per directory so named header types declared in a sibling file
// of the same package still resolve.
byDir := make(map[string][]string)
for _, file := range files {
dir := filepath.Dir(file)
byDir[dir] = append(byDir[dir], file)
}
dirs := make([]string, 0, len(byDir))
for dir := range byDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
fset := token.NewFileSet()
var violations []archHeaderReparse
for _, dir := range dirs {
astFiles := make([]*ast.File, 0, len(byDir[dir]))
for _, file := range byDir[dir] {
f, err := parser.ParseFile(fset, file, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
astFiles = append(astFiles, f)
}
named := archNamedStructIndex(astFiles)
for i, f := range astFiles {
rel := filepath.ToSlash(byDir[dir][i])
violations = append(violations, archFindHeaderReparses(fset, f, rel, named)...)
}
}
seen := make(map[string]bool)
for _, v := range violations {
seen[v.file+"\x00"+v.tag] = true
if slices.Contains(headerReparseBaseline[v.file], v.tag) {
continue
}
t.Errorf("%s:%d field %s re-parses envelope header metadata %q inside a json:\"header\" block — consume the kernel-decoded header instead of unmarshalling the envelope again", v.file, v.line, v.field, v.tag)
}
// Stale baseline entries: once a file stops re-parsing a tag, its entry
// must go, otherwise the ratchet is wider than reality and the cleanup
// can silently regress.
for file, tags := range headerReparseBaseline {
for _, tag := range tags {
if !seen[file+"\x00"+tag] {
t.Errorf("stale baseline entry %s / %q: no code matches it anymore — delete the entry so the cleanup is locked in", file, tag)
}
}
}
}
// TestArchEventsHeaderReparseDetectorSelfCheck runs the header-reparse
// detector on synthetic sources with a known violation count. If the
// detector rots (tag parsing, named-type resolution, header matching), the
// main gate would report green on a violating tree; this test makes that
// failure mode loud.
func TestArchEventsHeaderReparseDetectorSelfCheck(t *testing.T) {
parse := func(src string) []archHeaderReparse {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "synthetic.go", src, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse synthetic source: %v", err)
}
files := []*ast.File{f}
return archFindHeaderReparses(fset, f, "synthetic.go", archNamedStructIndex(files))
}
const violating = `package synth
type namedHeader struct {
AppID string ` + "`json:\"app_id\"`" + `
}
type envelope struct {
Header struct {
EventID string ` + "`json:\"event_id\"`" + `
TenantKey string ` + "`json:\"tenant_key,omitempty\"`" + `
Custom string ` + "`json:\"custom\"`" + `
} ` + "`json:\"header,omitempty\"`" + `
Named *namedHeader ` + "`json:\"header\"`" + `
Body struct {
CreateTime string ` + "`json:\"create_time\"`" + `
} ` + "`json:\"body\"`" + `
}
`
got := parse(violating)
gotIDs := make([]string, len(got))
for i, v := range got {
gotIDs[i] = v.field + ":" + v.tag
}
sort.Strings(gotIDs)
wantIDs := []string{"AppID:app_id", "EventID:event_id", "TenantKey:tenant_key"}
if !slices.Equal(gotIDs, wantIDs) {
t.Fatalf("detector self-check: flagged %v, want exactly %v — the detector has drifted and the main gate cannot be trusted", gotIDs, wantIDs)
}
const clean = `package synth
type output struct {
EventID string ` + "`json:\"event_id\"`" + `
Header struct {
Custom string ` + "`json:\"custom\"`" + `
} ` + "`json:\"header\"`" + `
}
`
if got := parse(clean); len(got) != 0 {
t.Fatalf("detector self-check: clean synthetic source flagged %+v — the detector over-triggers and will produce false reds", got)
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// compileRealCatalog compiles the full shipped declaration set exactly as the
// runtime does. Tests that used to walk the global registry iterate this
// snapshot instead.
func compileRealCatalog(t *testing.T) *catalog.Snapshot {
t.Helper()
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
return snap
}

93
events/compile_test.go Normal file
View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"encoding/json"
"testing"
"github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/event/catalog"
)
// This gate lives in the events package because the catalog package cannot
// import the declarations it compiles (that would be an import cycle). It is
// the acceptance half of the compiler's own rejection tests: the real catalog
// must compile — a compiler that rejects everything would also pass those.
func TestCompile_RealCatalogCompilesCleanly(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("the shipped declarations must compile: %v", err)
}
if snap.Len() == 0 {
t.Fatal("the compiled catalog is empty; the gate proved nothing")
}
if snap.Len() != len(expectedKeys) {
t.Fatalf("compiled %d keys, frozen baseline has %d", snap.Len(), len(expectedKeys))
}
for _, want := range expectedKeys {
if _, ok := snap.Resolve(want); !ok {
t.Errorf("baseline key missing from the compiled catalog: %s", want)
}
}
}
// Every shipped key must satisfy its compiled output contract: a resolvable
// non-empty schema, a jq root that matches the output mode, and normalized
// delivery values. Golden files pin a few representative keys byte-for-byte;
// this covers the whole catalog structurally.
func TestOutputContract_HoldsForEveryKey(t *testing.T) {
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatal(err)
}
checked := 0
for _, entry := range snap.Entries() {
checked++
d := entry.Descriptor()
out := entry.Output()
var parsed map[string]json.RawMessage
if err := json.Unmarshal(out.SchemaJSON, &parsed); err != nil || len(parsed) == 0 {
t.Errorf("%s: resolved schema must be a non-empty JSON object (err=%v)", d.Key, err)
}
switch out.Mode {
case catalog.OutputNative:
if out.JQRootPath != ".event" {
t.Errorf("%s: native keys deliver the V2 envelope; jq root must be .event, got %q", d.Key, out.JQRootPath)
}
if entry.Binding().Process != nil {
t.Errorf("%s: native keys must not carry a processor", d.Key)
}
case catalog.OutputProcessed:
if out.JQRootPath != "." {
t.Errorf("%s: processed keys deliver a flat shape; jq root must be ., got %q", d.Key, out.JQRootPath)
}
if entry.Binding().Process == nil {
t.Errorf("%s: processed keys must carry a processor", d.Key)
}
default:
t.Errorf("%s: unknown output mode %q", d.Key, out.Mode)
}
cap := entry.Capability()
if cap.BufferSize <= 0 || cap.BufferSize > catalog.MaxBufferSize || cap.Workers <= 0 {
t.Errorf("%s: delivery values must be normalized, got buffer=%d workers=%d", d.Key, cap.BufferSize, cap.Workers)
}
if d.Domain == "" {
t.Errorf("%s: descriptor domain must always be resolved", d.Key)
}
}
if checked == 0 {
t.Fatal("no entries were checked; the gate proved nothing")
}
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"testing"
)
// expectedKeys is the frozen catalog baseline. Adding, removing, or renaming
// an EventKey is a deliberate contract change: update this list in the same
// commit and call the change out in the changelog.
var expectedKeys = []string{
"application.bot.menu_v6",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"board.whiteboard.updated_v1",
"card.action.trigger",
"im.chat.disbanded_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"im.chat.member.user.added_v1",
"im.chat.member.user.deleted_v1",
"im.chat.member.user.withdrawn_v1",
"im.chat.updated_v1",
"im.message.message_read_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.message.receive_v1",
"minutes.minute.generated_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_ended_v1",
"vc.meeting.participant_meeting_joined_v1",
"vc.meeting.participant_meeting_started_v1",
"vc.note.generated_v1",
"vc.recording.recording_ended_v1",
"vc.recording.recording_started_v1",
"vc.recording.recording_transcript_generated_v1",
}
func TestRegisteredKeys_MatchFrozenBaseline(t *testing.T) {
all := compileRealCatalog(t).Definitions()
if len(all) == 0 {
t.Fatal("no EventKeys registered; the gate scanned nothing")
}
got := make(map[string]bool, len(all))
for _, def := range all {
got[def.Key] = true
}
for _, want := range expectedKeys {
if !got[want] {
t.Errorf("expected EventKey missing from registry: %s", want)
}
delete(got, want)
}
for extra := range got {
t.Errorf("EventKey not in frozen baseline (update expectedKeys deliberately): %s", extra)
}
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
@@ -35,11 +36,6 @@ type CardActionTriggerOutput struct {
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
@@ -64,7 +60,7 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
return nil, processing.DropMalformed(raw.EventType)
}
actionValue := marshalToString(envelope.Event.Action.Value)
@@ -72,9 +68,9 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,

View File

@@ -10,10 +10,11 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
def, ok := lookupCompiledDef(t, "card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
@@ -243,11 +244,11 @@ func TestProcessCardAction_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
@@ -415,6 +416,7 @@ func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionT
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
@@ -40,11 +41,6 @@ type MentionOutput struct {
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Message struct {
MessageID string `json:"message_id"`
@@ -68,7 +64,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
msg := envelope.Event.Message
@@ -82,14 +78,14 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
})
}
timestamp := envelope.Header.CreateTime
timestamp := raw.SourceTime
if timestamp == "" {
timestamp = msg.CreateTime
}
out := &ImMessageReceiveOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: timestamp,
ID: msg.MessageID,
MessageID: msg.MessageID,

View File

@@ -6,22 +6,15 @@ package im
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
def, ok := event.Lookup("im.message.receive_v1")
def, ok := lookupCompiledDef(t, "im.message.receive_v1")
if !ok {
t.Fatal("im.message.receive_v1 should be registered via Keys()")
}
@@ -53,7 +46,7 @@ func TestIMKeys_NativeEventsRegistered(t *testing.T) {
"im.chat.disbanded_v1",
}
for _, k := range want {
def, ok := event.Lookup(k)
def, ok := lookupCompiledDef(t, k)
if !ok {
t.Errorf("%s should be registered via Keys()", k)
continue
@@ -232,11 +225,11 @@ func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
@@ -248,6 +241,7 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
@@ -267,6 +261,7 @@ func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package subscribeprep provides the shared PreConsume hook for EventKeys
// whose server-side subscription is a plain event_type register/unregister
// pair against fixed OAPI paths.
package subscribeprep
import (
"context"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event/processing"
)
// CleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const CleanupTimeout = 5 * time.Second
// Hook returns a PreConsume that subscribes eventType via subscribePath and
// hands back a cleanup that unsubscribes it via unsubscribePath.
func Hook(eventType, subscribePath, unsubscribePath string) func(context.Context, processing.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt processing.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
return SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath)
}
}
// SubscribeWithCleanup calls the subscribe OAPI for eventType and returns a
// cleanup that invokes the matching unsubscribe, bounded by CleanupTimeout.
// rt must be non-nil; callers that validate their own params (e.g. to build
// per-resource paths) run those checks first and then delegate here.
func SubscribeWithCleanup(ctx context.Context, rt processing.APIClient, eventType, subscribePath, unsubscribePath string) (func() error, error) {
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), CleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}

View File

@@ -9,11 +9,19 @@ import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
for _, def := range event.ListAll() {
snap, err := catalog.Compile(All(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("compile catalog: %v", err)
}
for _, def := range snap.Definitions() {
if len(def.Schema.FieldOverrides) == 0 {
continue
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -36,11 +37,6 @@ type MinutesMinuteGeneratedOutput struct {
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
MinuteToken string `json:"minute_token"`
MinuteSource struct {
@@ -50,18 +46,15 @@ func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
out := &MinutesMinuteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
MinuteToken: envelope.Event.MinuteToken,
}
if out.Type == "" {
out.Type = raw.EventType
}
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.MinuteSource = &MinutesMinuteSourceOutput{
SourceType: src.SourceType,

View File

@@ -7,12 +7,12 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -35,17 +35,10 @@ func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string)
}
}
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
@@ -274,7 +267,7 @@ func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
@@ -326,14 +319,38 @@ func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
@@ -341,6 +358,7 @@ func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) Minute
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -7,6 +7,7 @@ package minutes
import (
"reflect"
"github.com/larksuite/cli/events/internal/subscribeprep"
"github.com/larksuite/cli/internal/event"
)
@@ -31,7 +32,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
},
Process: processMinutesMinuteGenerated,
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
Scopes: []string{"minutes:minutes.basic:read"},
AuthTypes: []string{
"user",

View File

@@ -0,0 +1,461 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"bytes"
"context"
"encoding/json"
"flag"
"maps"
"os"
"path/filepath"
"sort"
"testing"
"time"
event "github.com/larksuite/cli/internal/event"
)
var updateBaseline = flag.Bool("update-baseline", false,
"rewrite testdata/output_baseline.json with the current Processed EventKey outputs")
// TestMain pins the process timezone to UTC before any test runs. Several
// Process handlers format timestamps in the machine's local timezone
// (e.g. meeting start/end times, recording event times), so without the pin
// the snapshot would drift between machines in different timezones.
func TestMain(m *testing.M) {
time.Local = time.UTC
os.Exit(m.Run())
}
const baselineSnapshotPath = "testdata/output_baseline.json"
// wantProcessedKeys freezes how many registered EventKeys define Process
// (im 2, vc 7, minutes 1, application 1, approval 2). The count assertion
// keeps this test honest: if a Processed key is added or removed, the covered
// output surface changed and the baseline would silently widen or narrow
// without it. Update the count, the fixtures, and the snapshot together,
// deliberately.
const wantProcessedKeys = 13
const (
baselineEventID = "evt-baseline-001"
baselineCreateTime = "1700000000000" // 2023-11-14T22:13:20Z in milliseconds
)
// baselineFixture holds the minimal well-formed inputs for one Processed
// EventKey: the business body placed under "event" in the V2 envelope, plus
// any extra header fields the handler reads beyond event_id / event_type /
// create_time. Every fixture must drive Process down its success path — no
// drop, no malformed-payload passthrough.
type baselineFixture struct {
extraHeader map[string]string
eventBody string
}
// baselineFixtures maps every Processed EventKey to its synthetic input.
// Field values are fixed constants so the resulting output is byte-stable.
var baselineFixtures = map[string]baselineFixture{
"application.bot.menu_v6": {
extraHeader: map[string]string{
"app_id": "cli-baseline-app",
"tenant_key": "tenant-baseline",
},
// 10-digit seconds timestamp: the handler normalizes it to milliseconds.
eventBody: `{
"event_key": "baseline_menu_key",
"timestamp": 1700000000,
"operator": {
"operator_id": {
"open_id": "ou-baseline-operator",
"union_id": "on-baseline-operator",
"user_id": "user-baseline-operator"
},
"operator_name": "Baseline Operator"
}
}`,
},
"approval.instance.status_changed_v4": {
eventBody: `{
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"external_id": "external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"start_user": {
"open_id": "ou-baseline-starter",
"union_id": "on-baseline-starter",
"user_id": "user-baseline-starter"
}
}`,
},
"approval.task.status_changed_v4": {
eventBody: `{
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"task_id": "task-id-baseline",
"external_id": "external-id-baseline",
"task_external_id": "task-external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"assigned_user": {
"open_id": "ou-baseline-assignee",
"union_id": "on-baseline-assignee",
"user_id": "user-baseline-assignee"
}
}`,
},
// The card handler fetches the card content through the API client using
// context.open_message_id; the fake client below serves that request.
"card.action.trigger": {
eventBody: `{
"operator": {"open_id": "ou-baseline-operator"},
"token": "card-token-baseline",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "baseline"},
"name": "baseline_button",
"form_value": {"field": "value"},
"input_value": "baseline input",
"option": "opt-1",
"options": ["opt-1", "opt-2"],
"checked": true,
"timezone": "Asia/Shanghai"
},
"context": {
"open_message_id": "om-baseline-card",
"open_chat_id": "oc-baseline-chat"
}
}`,
},
// update_time differs from create_time so the handler emits both; the
// mention placeholder in content exercises mention rendering.
"im.message.receive_v1": {
eventBody: `{
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou-baseline-sender"}
},
"message": {
"message_id": "om-baseline-msg",
"root_id": "om-baseline-root",
"parent_id": "om-baseline-parent",
"thread_id": "omt-baseline-thread",
"chat_id": "oc-baseline-chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1699999999000",
"update_time": "1700000000500",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou-baseline-mention"},
"name": "Baseline User"
}
]
}
}`,
},
// The minutes handler enriches the output with the minute title via the
// API client; the fake client answers with a non-empty title on the first
// call so no retry attempt is made.
"minutes.minute.generated_v1": {
eventBody: `{
"minute_token": "minute-token-baseline",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
}`,
},
"vc.meeting.participant_meeting_started_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
"vc.meeting.participant_meeting_joined_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
"vc.meeting.participant_meeting_ended_v1": {
eventBody: `{
"meeting": {
"id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "1700000000",
"end_time": "1700000600",
"calendar_event_id": "calendar-event-baseline"
}
}`,
},
// The note handler enriches the output with document tokens via the API
// client; the fake client answers with both artifacts on the first call
// so no retry attempt is made.
"vc.note.generated_v1": {
eventBody: `{"note_id": "note-id-baseline"}`,
},
// Recording handlers only emit events whose source is recording_bean;
// anything else is dropped, which would break the success-path contract.
"vc.recording.recording_started_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean"
}`,
},
"vc.recording.recording_transcript_generated_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {"user_name": "Baseline Speaker"},
"text": "baseline transcript text",
"start_time_ms": "1700000000000",
"end_time_ms": "1700000001000",
"sentence_id": "sentence-baseline-1"
}
]
}`,
},
"vc.recording.recording_ended_v1": {
eventBody: `{
"unique_key": "recording-key-baseline",
"source": "recording_bean"
}`,
},
}
// baselineAPIResponses maps request paths to canned success responses for the
// handlers that call the API during Process. Every response satisfies the
// handler on the first call, so retry loops never engage and no real network
// or credentials are involved.
var baselineAPIResponses = map[string]string{
"/open-apis/im/v1/messages/om-baseline-card?card_msg_content_type=user_card_content": `{
"code": 0,
"msg": "success",
"data": {
"items": [
{"body": {"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"}}
]
}
}`,
"/open-apis/vc/v1/notes/note-id-baseline": `{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "note-doc-token-baseline"},
{"artifact_type": 2, "doc_token": "verbatim-doc-token-baseline"}
],
"note_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
}
}
}`,
"/open-apis/minutes/v1/minutes/minute-token-baseline": `{
"code": 0,
"msg": "success",
"data": {
"minute": {"title": "Baseline minute title"}
}
}`,
}
// baselineAPIClient serves the canned responses above. An unexpected request
// path fails the test immediately instead of returning an error, because
// several handlers swallow API errors (or retry with delays) and would
// silently produce a degraded output that gets frozen into the baseline.
type baselineAPIClient struct {
t *testing.T
}
func (c *baselineAPIClient) CallAPI(_ context.Context, method, path string, _ any) (json.RawMessage, error) {
c.t.Helper()
resp, ok := baselineAPIResponses[path]
if !ok {
c.t.Fatalf("unexpected API call during Process: %s %s — add a canned response to baselineAPIResponses", method, path)
}
return json.RawMessage(resp), nil
}
// TestProcessedOutputBaseline runs every Processed EventKey against a fixed
// well-formed synthetic payload and compares the outputs with the frozen
// snapshot in testdata/output_baseline.json. Any change to what a Processed
// key writes to stdout for a well-formed event shows up here as a named,
// per-key diff. Run with -update-baseline to accept an intentional change.
func TestProcessedOutputBaseline(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
rt := &baselineAPIClient{t: t}
got := map[string]json.RawMessage{}
seenFixtures := map[string]bool{}
for _, def := range compileRealCatalog(t).Definitions() {
if def.Process == nil {
continue
}
fx, ok := baselineFixtures[def.Key]
if !ok {
t.Fatalf("Processed EventKey %q has no baseline fixture; add one to baselineFixtures, bump wantProcessedKeys, and regenerate with -update-baseline", def.Key)
}
seenFixtures[def.Key] = true
payload := buildBaselineEnvelope(t, def.EventType, fx)
// The canonical fields mirror the synthetic envelope header exactly,
// including any extra header fields, just as the consume pipeline
// guarantees for real events before Process runs.
raw := &event.RawEvent{
EventID: baselineEventID,
EventType: def.EventType,
SourceTime: baselineCreateTime,
AppID: fx.extraHeader["app_id"],
TenantKey: fx.extraHeader["tenant_key"],
Payload: payload,
Timestamp: time.Unix(1700000000, 0).UTC(),
}
out, err := def.Process(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("%s: Process returned error on well-formed payload: %v", def.Key, err)
}
if out == nil {
t.Fatalf("%s: Process dropped a well-formed payload; the fixture must exercise the success path", def.Key)
}
if bytes.Equal(compactJSON(t, def.Key, out), compactJSON(t, def.Key, payload)) {
t.Fatalf("%s: Process returned the input unchanged; the fixture must exercise the success path, not the malformed-payload passthrough", def.Key)
}
got[def.Key] = out
}
if len(got) != wantProcessedKeys {
t.Fatalf("processed %d EventKeys, want exactly %d; a Processed key was added or removed — update baselineFixtures, wantProcessedKeys, and the snapshot together (keys run: %v)",
len(got), wantProcessedKeys, sortedKeys(got))
}
for key := range baselineFixtures {
if !seenFixtures[key] {
t.Fatalf("baseline fixture %q matches no registered Processed EventKey; remove it or fix the key name", key)
}
}
if *updateBaseline {
writeBaselineSnapshot(t, got)
return
}
compareBaselineSnapshot(t, got)
}
// buildBaselineEnvelope wraps a fixture body in the standard V2 event
// envelope with fixed header values.
func buildBaselineEnvelope(t *testing.T, eventType string, fx baselineFixture) json.RawMessage {
t.Helper()
header := map[string]string{
"event_id": baselineEventID,
"event_type": eventType,
"create_time": baselineCreateTime,
}
maps.Copy(header, fx.extraHeader)
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal envelope header: %v", err)
}
envelope := map[string]json.RawMessage{
"schema": json.RawMessage(`"2.0"`),
"header": headerJSON,
"event": json.RawMessage(fx.eventBody),
}
payload, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope for %s: %v", eventType, err)
}
return payload
}
func writeBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) {
t.Helper()
// MarshalIndent sorts map keys, so the snapshot is deterministic.
data, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(baselineSnapshotPath), 0o755); err != nil {
t.Fatalf("create testdata dir: %v", err)
}
if err := os.WriteFile(baselineSnapshotPath, data, 0o644); err != nil {
t.Fatalf("write snapshot: %v", err)
}
t.Logf("baseline snapshot rewritten: %s (%d keys)", baselineSnapshotPath, len(got))
}
func compareBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) {
t.Helper()
data, err := os.ReadFile(baselineSnapshotPath)
if os.IsNotExist(err) {
t.Fatalf("baseline snapshot %s not found; generate it with: go test ./events/ -run TestProcessedOutput -update-baseline", baselineSnapshotPath)
}
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
var want map[string]json.RawMessage
if err := json.Unmarshal(data, &want); err != nil {
t.Fatalf("snapshot %s is not valid JSON: %v", baselineSnapshotPath, err)
}
for _, key := range sortedKeys(want) {
if _, ok := got[key]; !ok {
t.Errorf("%s: present in snapshot but produced no output this run; if the key was removed on purpose, regenerate with -update-baseline", key)
}
}
for _, key := range sortedKeys(got) {
wantOut, ok := want[key]
if !ok {
t.Errorf("%s: produced output but missing from snapshot; regenerate with -update-baseline", key)
continue
}
gotC := compactJSON(t, key, got[key])
wantC := compactJSON(t, key, wantOut)
if !bytes.Equal(gotC, wantC) {
t.Errorf("%s: Processed output drifted from baseline\n got: %s\n want: %s\nIf this change is intentional, regenerate with -update-baseline", key, gotC, wantC)
}
}
}
// compactJSON canonicalizes whitespace so comparisons are content-only.
func compactJSON(t *testing.T, key string, raw json.RawMessage) []byte {
t.Helper()
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
t.Fatalf("%s: output is not valid JSON: %v\nraw=%s", key, err, string(raw))
}
return buf.Bytes()
}
func sortedKeys(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
event "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// closureAPIClient answers any API call with a benign error: a handler facing
// a malformed payload must decide to drop before it ever needs the API.
type closureAPIClient struct{}
func (closureAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) {
return nil, errors.New("no API access for malformed input")
}
// Every Processed EventKey declares an output schema; its stdout must stay
// inside that schema. A payload that cannot be decoded therefore has exactly
// one legal outcome: a malformed drop. Passing the raw envelope through would
// hand consumers a shape the schema never described.
//
// Native keys (Process == nil) are exempt by contract: their declared output
// is the raw envelope itself.
func TestAllKeys_MalformedPayloadStaysSchemaClosed(t *testing.T) {
const wantProcessedKeys = 13
processed := 0
for _, def := range compileRealCatalog(t).Definitions() {
if def.Process == nil {
continue
}
processed++
out, err := safeProcess(t, def, json.RawMessage(`this is definitely not valid json {{{`))
if out != nil {
t.Errorf("%s: malformed payload produced stdout output; it must be dropped", def.Key)
}
if !processing.IsDropMalformed(err) {
t.Errorf("%s: malformed payload must be dropped with a malformed marker, got err=%v", def.Key, err)
}
}
if processed == 0 {
t.Fatal("no processed keys were exercised; the gate scanned nothing")
}
if processed != wantProcessedKeys {
t.Fatalf("exercised %d processed keys, want exactly %d; update the count when keys are deliberately added or removed", processed, wantProcessedKeys)
}
}
// safeProcess isolates a panicking handler to a per-key finding instead of
// aborting the whole gate: a handler that dereferences before decoding is a
// bug in that key, not a reason to stop scanning the rest.
func safeProcess(t *testing.T, def *event.KeyDefinition, payload json.RawMessage) (out json.RawMessage, err error) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Errorf("%s: Process panicked on malformed payload: %v", def.Key, r)
out, err = nil, nil
}
}()
raw := &event.RawEvent{
EventID: "evt-closure-1",
EventType: def.EventType,
Payload: payload,
Timestamp: time.Unix(0, 0),
}
return def.Process(context.Background(), closureAPIClient{}, raw, map[string]string{})
}

View File

@@ -0,0 +1,259 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events_test
import (
"bytes"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"testing"
"github.com/larksuite/cli/internal/event/catalog"
)
// The output baseline freezes what every Processed key writes to stdout; the
// compiled catalog promises a schema for the same bytes. This test closes the
// loop between the two: every frozen output must be an instance of its key's
// resolved schema, so a schema and its real output can never drift apart with
// both sides individually green.
//
// The repository deliberately carries no JSON Schema validation dependency,
// so validation is done by a minimal in-repo checker that covers exactly the
// subset the catalog compiler emits (see validateValue). Any schema construct
// outside that subset is a loud failure, never a silent pass.
func TestProcessedBaselineOutputs_ConformToDeclaredSchemas(t *testing.T) {
snap := compileRealCatalog(t)
baseline := readBaselineSnapshot(t)
validated := 0
for _, entry := range snap.Entries() {
out := entry.Output()
if out.Mode != catalog.OutputProcessed {
continue
}
key := entry.Descriptor().Key
frozen, ok := baseline[key]
if !ok {
t.Errorf("%s: Processed key has no entry in %s; regenerate the baseline first", key, baselineSnapshotPath)
continue
}
schema := decodeSchemaNode(t, key, out.SchemaJSON)
instance := decodeInstance(t, key, frozen)
for _, problem := range validateValue("$", schema, instance) {
t.Errorf("%s: frozen output violates the declared schema: %s", key, problem)
}
validated++
}
// Idle detection, both directions: every Processed key was checked
// against a baseline entry, and no baseline entry escaped the check.
if validated == 0 {
t.Fatal("no Processed key was validated; the gate scanned nothing")
}
if validated != len(baseline) {
t.Fatalf("validated %d Processed keys but the baseline holds %d entries — a baseline entry matches no compiled Processed key (keys: %v)",
validated, len(baseline), sortedKeys(baseline))
}
}
// The validator itself must bite: an output tampered with in memory — an
// undeclared field, a primitive type flip — has to produce findings,
// otherwise a green conformance run proves nothing. The baseline file is
// never modified.
func TestSchemaInstanceValidator_BitesOnTamperedOutput(t *testing.T) {
const key = "im.message.receive_v1"
snap := compileRealCatalog(t)
entry, ok := snap.Resolve(key)
if !ok {
t.Fatalf("key %s is gone from the catalog; pick another Processed key for this self-check", key)
}
baseline := readBaselineSnapshot(t)
frozen, ok := baseline[key]
if !ok {
t.Fatalf("key %s has no baseline entry; the self-check needs a real frozen output", key)
}
schema := decodeSchemaNode(t, key, entry.Output().SchemaJSON)
// Control: the untampered output is conformant, so any finding below is
// caused by the tampering alone.
if problems := validateValue("$", schema, decodeInstance(t, key, frozen)); len(problems) != 0 {
t.Fatalf("control failed: the untampered output already has findings: %v", problems)
}
tampered, ok := decodeInstance(t, key, frozen).(map[string]any)
if !ok {
t.Fatalf("baseline output for %s is not a JSON object", key)
}
tampered["field_the_schema_never_declared"] = "smuggled"
if problems := validateValue("$", schema, tampered); len(problems) != 1 {
t.Errorf("an undeclared field must produce exactly one finding, got: %v", problems)
}
flipped, _ := decodeInstance(t, key, frozen).(map[string]any)
flipped["message_id"] = true // declared as a string
if problems := validateValue("$", schema, flipped); len(problems) != 1 {
t.Errorf("a primitive type flip must produce exactly one finding, got: %v", problems)
}
}
func readBaselineSnapshot(t *testing.T) map[string]json.RawMessage {
t.Helper()
data, err := os.ReadFile(baselineSnapshotPath)
if err != nil {
t.Fatalf("read %s: %v", baselineSnapshotPath, err)
}
var out map[string]json.RawMessage
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("%s is not valid JSON: %v", baselineSnapshotPath, err)
}
return out
}
func decodeSchemaNode(t *testing.T, key string, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("%s: resolved schema is not a JSON object: %v", key, err)
}
return schema
}
// decodeInstance parses a frozen output with UseNumber so integer/number
// checks see the literal digits instead of a lossy float64.
func decodeInstance(t *testing.T, key string, raw json.RawMessage) any {
t.Helper()
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
t.Fatalf("%s: baseline output is not valid JSON: %v", key, err)
}
return v
}
// validateValue checks one instance value against one schema node and returns
// the problems found. It implements only the subset the catalog compiler can
// emit (schemas.FromType plus raw declarations shaped the same way):
//
// - type object with properties: every instance field must be declared in
// properties and conform to its node; undeclared fields are errors.
// Absent declared fields are legal (handlers omit empty members).
// - type string / integer / number / boolean: the JSON value kind must
// match.
// - type array with items: every element must conform to items.
//
// description/format/enum annotations are metadata, not instance constraints
// here. Any construct outside the subset — a missing or unknown type, an
// object without properties, additionalProperties, an array without items —
// is reported as a problem so the validator can only be extended
// deliberately, never bypassed by a schema it does not understand.
func validateValue(path string, schema map[string]any, value any) []string {
typ, ok := schema["type"].(string)
if !ok {
return []string{fmt.Sprintf("%s: schema node has no \"type\"; outside the minimal validator subset, extend the validator deliberately", path)}
}
switch typ {
case "object":
obj, ok := value.(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: schema declares object, output has %s", path, jsonKind(value))}
}
if _, has := schema["additionalProperties"]; has {
return []string{fmt.Sprintf("%s: schema uses additionalProperties; outside the minimal validator subset, extend the validator deliberately", path)}
}
props, ok := schema["properties"].(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: object schema without properties; outside the minimal validator subset, extend the validator deliberately", path)}
}
var problems []string
for _, field := range sortedFieldNames(obj) {
fieldPath := path + "." + field
node, declared := props[field]
if !declared {
problems = append(problems, fmt.Sprintf("%s: field is not declared in the schema properties", fieldPath))
continue
}
nodeObj, ok := node.(map[string]any)
if !ok {
problems = append(problems, fmt.Sprintf("%s: schema property is not an object", fieldPath))
continue
}
problems = append(problems, validateValue(fieldPath, nodeObj, obj[field])...)
}
return problems
case "string":
if _, ok := value.(string); !ok {
return []string{fmt.Sprintf("%s: schema declares string, output has %s", path, jsonKind(value))}
}
case "boolean":
if _, ok := value.(bool); !ok {
return []string{fmt.Sprintf("%s: schema declares boolean, output has %s", path, jsonKind(value))}
}
case "integer":
num, ok := value.(json.Number)
if !ok {
return []string{fmt.Sprintf("%s: schema declares integer, output has %s", path, jsonKind(value))}
}
if _, err := strconv.ParseInt(num.String(), 10, 64); err != nil {
return []string{fmt.Sprintf("%s: schema declares integer, output has non-integer number %s", path, num)}
}
case "number":
if _, ok := value.(json.Number); !ok {
return []string{fmt.Sprintf("%s: schema declares number, output has %s", path, jsonKind(value))}
}
case "array":
arr, ok := value.([]any)
if !ok {
return []string{fmt.Sprintf("%s: schema declares array, output has %s", path, jsonKind(value))}
}
items, ok := schema["items"].(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: array schema without items; outside the minimal validator subset, extend the validator deliberately", path)}
}
var problems []string
for i, elem := range arr {
problems = append(problems, validateValue(fmt.Sprintf("%s[%d]", path, i), items, elem)...)
}
return problems
default:
return []string{fmt.Sprintf("%s: schema type %q; outside the minimal validator subset, extend the validator deliberately", path, typ)}
}
return nil
}
// jsonKind names a decoded JSON value's kind for problem messages.
func jsonKind(v any) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case string:
return "string"
case json.Number:
return "number"
case []any:
return "array"
case map[string]any:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}
func sortedFieldNames(obj map[string]any) []string {
names := make([]string, 0, len(obj))
for name := range obj {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -8,7 +8,7 @@ import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
"github.com/larksuite/cli/internal/event/schemas"
)
@@ -83,13 +83,14 @@ func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
if _, ok := snap.Resolve(key); !ok {
t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key)
}
}

177
events/testdata/output_baseline.json vendored Normal file
View File

@@ -0,0 +1,177 @@
{
"application.bot.menu_v6": {
"type": "application.bot.menu_v6",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"app_id": "cli-baseline-app",
"tenant_key": "tenant-baseline",
"event_key": "baseline_menu_key",
"menu_timestamp": "1700000000000",
"operator_id": "ou-baseline-operator",
"operator_open_id": "ou-baseline-operator",
"operator_union_id": "on-baseline-operator",
"operator_user_id": "user-baseline-operator",
"operator_name": "Baseline Operator"
},
"approval.instance.status_changed_v4": {
"type": "approval.instance.status_changed_v4",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"external_id": "external-id-baseline",
"status": "APPROVED",
"operate_time": "1700000000000",
"start_user": {
"open_id": "ou-baseline-starter",
"union_id": "on-baseline-starter",
"user_id": "user-baseline-starter"
}
},
"approval.task.status_changed_v4": {
"type": "approval.task.status_changed_v4",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"approval_code": "approval-code-baseline",
"instance_code": "instance-code-baseline",
"task_id": "task-id-baseline",
"external_id": "external-id-baseline",
"task_external_id": "task-external-id-baseline",
"assigned_user": {
"open_id": "ou-baseline-assignee",
"union_id": "on-baseline-assignee",
"user_id": "user-baseline-assignee"
},
"status": "APPROVED",
"operate_time": "1700000000000"
},
"card.action.trigger": {
"type": "card.action.trigger",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"operator_id": "ou-baseline-operator",
"message_id": "om-baseline-card",
"chat_id": "oc-baseline-chat",
"host": "im_message",
"token": "card-token-baseline",
"action_tag": "button",
"action_value": "{\"key\":\"baseline\"}",
"action_name": "baseline_button",
"form_value": "{\"field\":\"value\"}",
"input_value": "baseline input",
"option": "opt-1",
"options": "opt-1,opt-2",
"checked": true,
"timezone": "Asia/Shanghai",
"card_content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"
},
"im.message.receive_v1": {
"type": "im.message.receive_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"id": "om-baseline-msg",
"message_id": "om-baseline-msg",
"create_time": "1699999999000",
"update_time": "1700000000500",
"chat_id": "oc-baseline-chat",
"chat_type": "p2p",
"message_type": "text",
"sender_id": "ou-baseline-sender",
"sender_type": "user",
"root_id": "om-baseline-root",
"thread_id": "omt-baseline-thread",
"reply_to": "om-baseline-parent",
"content": "hello @Baseline User",
"mentions": [
{
"key": "@_user_1",
"id": "ou-baseline-mention",
"name": "Baseline User"
}
]
},
"minutes.minute.generated_v1": {
"type": "minutes.minute.generated_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"minute_token": "minute-token-baseline",
"title": "Baseline minute title",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
},
"vc.meeting.participant_meeting_ended_v1": {
"type": "vc.meeting.participant_meeting_ended_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"end_time": "2023-11-14T22:23:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.meeting.participant_meeting_joined_v1": {
"type": "vc.meeting.participant_meeting_joined_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.meeting.participant_meeting_started_v1": {
"type": "vc.meeting.participant_meeting_started_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"meeting_id": "meeting-id-baseline",
"topic": "Baseline meeting",
"meeting_no": "123456789",
"start_time": "2023-11-14T22:13:20Z",
"calendar_event_id": "calendar-event-baseline"
},
"vc.note.generated_v1": {
"type": "vc.note.generated_v1",
"event_id": "evt-baseline-001",
"timestamp": "1700000000000",
"note_id": "note-id-baseline",
"note_token": "note-doc-token-baseline",
"verbatim_token": "verbatim-doc-token-baseline",
"note_source": {
"source_type": "meeting",
"source_entity_id": "meeting-entity-baseline"
}
},
"vc.recording.recording_ended_v1": {
"type": "vc.recording.recording_ended_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean"
},
"vc.recording.recording_started_v1": {
"type": "vc.recording.recording_started_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean"
},
"vc.recording.recording_transcript_generated_v1": {
"type": "vc.recording.recording_transcript_generated_v1",
"event_id": "evt-baseline-001",
"event_time": "2023-11-14T22:13:20Z",
"unique_key": "recording-key-baseline",
"source": "recording_bean",
"transcript_items": [
{
"speaker_name": "Baseline Speaker",
"text": "baseline transcript text",
"start_time": "2023-11-14T22:13:20Z",
"end_time": "2023-11-14T22:13:21Z",
"sentence_id": "sentence-baseline-1"
}
]
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/catalog"
)
// lookupCompiledDef compiles this domain's declarations and resolves one key,
// exactly as the runtime catalog would for a consumer.
func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) {
t.Helper()
snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{
catalog.StrategyNone,
catalog.StrategyLegacyPreConsume,
})
if err != nil {
t.Fatalf("catalog.Compile(Keys()): %v", err)
}
entry, ok := snap.Resolve(key)
if !ok {
return nil, false
}
return entry.Definition(), true
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// recordingBeanSource is the only recording source the vc.recording.* keys
// emit; events carrying any other source are silently filtered out.
const recordingBeanSource = "recording_bean"
// recordingBeanEventBody is the shared {"event": ...} body for
// recording_started and recording_ended, whose payloads carry identical fields.
type recordingBeanEventBody struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
// decodeEventBody unmarshals the {"event": ...} envelope of raw and returns
// the decoded body; ok is false when the payload does not decode.
func decodeEventBody[T any](raw *event.RawEvent) (T, bool) {
var envelope struct {
Event T `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
var zero T
return zero, false
}
return envelope.Event, true
}
// millisToLocalRFC3339 converts a unix-millisecond timestamp string to
// RFC3339 in the local timezone; empty or non-numeric input yields "".
func millisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
// unixSecondsToLocalRFC3339 converts a unix-second timestamp string to
// RFC3339 in the local timezone; empty or non-numeric input yields "".
func unixSecondsToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
secs, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).Local().Format(time.RFC3339)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
"github.com/larksuite/cli/internal/validate"
)
@@ -42,28 +43,20 @@ type VCNoteGeneratedOutput struct {
func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
NoteID string `json:"note_id"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
return nil, processing.DropMalformed(raw.EventType)
}
out := &VCNoteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
NoteID: envelope.Event.NoteID,
}
if out.Type == "" {
out.Type = raw.EventType
}
if rt != nil && out.NoteID != "" {
fillVCNoteGeneratedDetails(ctx, rt, out)

View File

@@ -10,12 +10,13 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
def, ok := lookupCompiledDef(t, eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
@@ -113,7 +114,7 @@ func TestProcessVCNoteGenerated(t *testing.T) {
func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
def, ok := lookupCompiledDef(t, eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
@@ -301,11 +302,11 @@ func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
@@ -316,6 +317,7 @@ func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGe
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processVCNoteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -6,10 +6,9 @@ package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
@@ -25,33 +24,28 @@ type VCParticipantMeetingEndedOutput struct {
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
type participantMeetingEndedEvent struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
}
func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
body, ok := decodeEventBody[participantMeetingEndedEvent](raw)
if !ok {
return nil, processing.DropMalformed(raw.EventType)
}
meeting := envelope.Event.Meeting
meeting := body.Meeting
out := &VCParticipantMeetingEndedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
@@ -59,19 +53,5 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func unixSecondsToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
secs, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).Local().Format(time.RFC3339)
}

View File

@@ -6,24 +6,17 @@ package vc
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMeetingEnded)
def, ok := lookupCompiledDef(t, eventTypeMeetingEnded)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
}
@@ -130,18 +123,18 @@ func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
}
func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
def, ok := lookupCompiledDef(t, "vc.meeting.participant_meeting_ended_v1")
if !ok {
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
}
@@ -191,6 +184,7 @@ func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutp
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
@@ -22,41 +23,33 @@ type VCParticipantMeetingJoinedOutput struct {
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
type participantMeetingJoinedEvent struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
}
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
body, ok := decodeEventBody[participantMeetingJoinedEvent](raw)
if !ok {
return nil, processing.DropMalformed(raw.EventType)
}
meeting := envelope.Event.Meeting
meeting := body.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
@@ -24,7 +25,7 @@ func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
def, ok := lookupCompiledDef(t, tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
@@ -193,11 +194,11 @@ func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
})
}
@@ -208,7 +209,7 @@ func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testin
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
def, ok := lookupCompiledDef(t, eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
@@ -273,6 +274,7 @@ func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.Proces
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
@@ -22,40 +23,32 @@ type VCParticipantMeetingStartedOutput struct {
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
type participantMeetingStartedEvent struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
}
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
body, ok := decodeEventBody[participantMeetingStartedEvent](raw)
if !ok {
return nil, processing.DropMalformed(raw.EventType)
}
meeting := envelope.Event.Meeting
meeting := body.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
Type: raw.EventType,
EventID: raw.EventID,
Timestamp: raw.SourceTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -6,10 +6,9 @@ package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
@@ -21,64 +20,20 @@ type VCRecordingEndedOutput struct {
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingEndedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingEndedEvent `json:"event"`
}
type recordingEndedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingEndedEnvelope(raw)
body, ok := decodeEventBody[recordingBeanEventBody](raw)
if !ok {
return raw.Payload, nil
return nil, processing.DropMalformed(raw.EventType)
}
if !isRecordingEndedBeanEvent(envelope) {
if body.Source != recordingBeanSource {
return nil, nil
}
out := &VCRecordingEndedOutput{
Type: recordingEndedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
Type: raw.EventType,
EventID: raw.EventID,
EventTime: millisToLocalRFC3339(raw.SourceTime),
UniqueKey: body.UniqueKey,
Source: body.Source,
}
return json.Marshal(out)
}
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
var envelope recordingEndedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingEndedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -6,10 +6,9 @@ package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
@@ -21,64 +20,20 @@ type VCRecordingStartedOutput struct {
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingStartedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingStartedEvent `json:"event"`
}
type recordingStartedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingStartedEnvelope(raw)
body, ok := decodeEventBody[recordingBeanEventBody](raw)
if !ok {
return raw.Payload, nil
return nil, processing.DropMalformed(raw.EventType)
}
if !isRecordingStartedBeanEvent(envelope) {
if body.Source != recordingBeanSource {
return nil, nil
}
out := &VCRecordingStartedOutput{
Type: recordingStartedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
Type: raw.EventType,
EventID: raw.EventID,
EventTime: millisToLocalRFC3339(raw.SourceTime),
UniqueKey: body.UniqueKey,
Source: body.Source,
}
return json.Marshal(out)
}
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
var envelope recordingStartedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingStartedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
@@ -25,7 +26,7 @@ func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
def, ok := lookupCompiledDef(t, tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
@@ -351,7 +352,7 @@ func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
}
}
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
func TestProcessVCRecording_MalformedPayloadDrop(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
@@ -370,11 +371,11 @@ func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
if !processing.IsDropMalformed(err) {
t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
if got != nil {
t.Errorf("malformed payload must be dropped without output, got %q", string(got))
}
})
}
@@ -391,7 +392,7 @@ func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
def, ok := lookupCompiledDef(t, tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
@@ -456,6 +457,7 @@ func runRecordingProcessRaw(t *testing.T, eventType string, process event.Proces
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
fillCanonicalFromHeader(t, raw)
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)

View File

@@ -6,10 +6,9 @@ package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/processing"
)
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
@@ -31,15 +30,6 @@ type VCRecordingTranscriptGeneratedOutput struct {
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
}
type recordingTranscriptGeneratedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingTranscriptGeneratedEvent `json:"event"`
}
type recordingTranscriptGeneratedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
@@ -61,58 +51,24 @@ type recordingTranscriptGeneratedSpeakerIn struct {
type recordingTranscriptGeneratedString string
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
body, ok := decodeEventBody[recordingTranscriptGeneratedEvent](raw)
if !ok {
return raw.Payload, nil
return nil, processing.DropMalformed(raw.EventType)
}
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
if body.Source != recordingBeanSource {
return nil, nil
}
out := &VCRecordingTranscriptGeneratedOutput{
Type: recordingTranscriptGeneratedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
Type: raw.EventType,
EventID: raw.EventID,
EventTime: millisToLocalRFC3339(raw.SourceTime),
UniqueKey: body.UniqueKey,
Source: body.Source,
TranscriptItems: recordingTranscriptItems(body.TranscriptItems),
}
return json.Marshal(out)
}
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
var envelope recordingTranscriptGeneratedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingTranscriptGeneratedEventTime(raw string) string {
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
}
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
if len(items) == 0 {
return nil
@@ -128,8 +84,8 @@ func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordin
return VCRecordingTranscriptItemOutput{
SpeakerName: recordingSpeakerName(item.Speaker),
Text: item.Text,
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
StartTime: millisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: millisToLocalRFC3339(item.EndTimeMs.String()),
SentenceID: item.SentenceID,
}
}

View File

@@ -7,6 +7,7 @@ package vc
import (
"reflect"
"github.com/larksuite/cli/events/internal/subscribeprep"
"github.com/larksuite/cli/internal/event"
)
@@ -41,7 +42,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
@@ -57,7 +58,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
@@ -73,7 +74,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
},
Process: processVCParticipantMeetingEnded,
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
@@ -89,7 +90,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})},
},
Process: processVCNoteGenerated,
PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
Scopes: []string{"vc:note:read"},
AuthTypes: []string{
"user",
@@ -105,7 +106,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
},
Process: processVCRecordingStarted,
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
@@ -121,7 +122,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
},
Process: processVCRecordingTranscriptGenerated,
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
@@ -137,7 +138,7 @@ func Keys() []event.KeyDefinition {
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
},
Process: processVCRecordingEnded,
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
PreConsume: subscribeprep.Hook(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",

View File

@@ -8,8 +8,34 @@ import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
)
// fillCanonicalFromHeader copies the payload envelope header metadata onto
// the RawEvent canonical fields. Process handlers read event_id and
// create_time from the RawEvent, which the consume pipeline fills from the
// envelope header before dispatch; tests that hand-build a RawEvent must
// mirror that so both views agree.
func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) {
t.Helper()
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
t.Fatalf("parse envelope header: %v", err)
}
raw.EventID = envelope.Header.EventID
if envelope.Header.EventType != "" {
raw.EventType = envelope.Header.EventType
}
raw.SourceTime = envelope.Header.CreateTime
}
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}

View File

@@ -6,17 +6,13 @@ package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/events/internal/subscribeprep"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
@@ -39,18 +35,6 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
return subscribeprep.SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath)
}
}

View File

@@ -24,10 +24,15 @@ func Keys() []event.KeyDefinition {
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
// The server-side subscription is keyed per whiteboard, so
// the id must be part of the consumer's subscription
// identity: consumers of different whiteboards get their
// own setup/cleanup lifecycle instead of sharing one.
SubscriptionKey: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"testing"
event "github.com/larksuite/cli/internal/event"
)
// The whiteboard subscription is registered per whiteboard on the server, so
// the whiteboard id must take part in the consumer's subscription identity.
// Without it, two consumers of different whiteboards share one scope: the
// second consumer's setup never runs (its whiteboard is never subscribed) and
// whichever exits last unsubscribes the other one's still-active board.
func TestWhiteboardID_IsPartOfSubscriptionIdentity(t *testing.T) {
defs := Keys()
if len(defs) != 1 {
t.Fatalf("expected exactly one whiteboard key, got %d", len(defs))
}
def := defs[0]
var found *event.ParamDef
for i := range def.Params {
if def.Params[i].Name == "whiteboard_id" {
found = &def.Params[i]
}
}
if found == nil {
t.Fatal("whiteboard_id param is missing")
}
if !found.SubscriptionKey {
t.Error("whiteboard_id must be a subscription key: the server-side subscription is per-whiteboard")
}
}

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,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

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