Compare commits

...

31 Commits

Author SHA1 Message Date
liangshuo-1
d126ea2f92 chore(release): v1.0.44 (#1176) 2026-05-29 19:43:31 +08:00
max
1ba107da2e fix(vc): correct --minute-token to --minute-tokens in recording reference (#1170)
Fix 3 occurrences of --minute-token (singular) to --minute-tokens
(plural) in lark-vc-recording.md to match the actual CLI flag
definition in minutes_download.go.
2026-05-29 16:42:54 +08:00
yballul-bytedance
0e6274d947 feat(base): add dashboard block data shortcut and workflow docs (#1067)
Change-Id: I52c471886bdb2d4b7be021ce86c34bbb78385017
2026-05-29 16:35:32 +08:00
lhfer
e18ea9a2e8 fix(im): correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
The size==1 (64-bit "largesize") branch of all three MP4 box walkers
(findMP4Box, readMp4DurationBytes, readMp4Duration) set boxEnd to the raw
largesize instead of offset+largesize — even though the 32-bit branch right
below correctly uses offset+size. Two consequences:

- Correctness: for any MP4 that carries a 64-bit box size at a non-zero
  offset, the box walk is computed from the wrong end, so the moov/mvhd
  lookup is truncated and the media duration is silently lost.

- Robustness/security (CWE-190): the unguarded uint64->int(64) conversion of
  a largesize with the high bit set yields a negative boxEnd. The in-memory
  walkers then assign it to offset and feed it back as a slice index
  (data[offset:]), panicking with "slice bounds out of range" and crashing
  the CLI on a crafted or corrupt MP4. This is reachable via URL-sourced IM
  media, whose bytes the caller does not control.

Fix: compute boxEnd as offset+largesize (matching the 32-bit branch) and
reject largesize values smaller than the 16-byte header or larger than the
remaining input. Malformed media now honours the parsers' best-effort
contract by returning 0/-1 instead of panicking, and the bounds guarantee
the conversion can no longer overflow.

Add regression tests covering both the overflow (must not panic) and a
64-bit box at a non-zero offset (must walk correctly).
2026-05-29 16:04:21 +08:00
shifengjuan-dev
365e0a2880 feat(im/chat-list): support --types flag for listing p2p single chats (#1077)
Add a new --types flag (string_slice; values from {group, p2p}) to
+chat-list, backed by the new GET /open-apis/im/v1/chats `types` query
parameter. Accepts CSV (--types group,p2p) and repeated-flag forms
(--types group --types p2p).

Defaults to groups-only (backward compatible). Under user identity,
p2p single chats appear with chat_mode="p2p" plus p2p_target_type /
p2p_target_id fields. Under bot identity:

  - --types=p2p alone is rejected at validation
  - --types=p2p,group is silently downgraded to types=group (no runtime
    notice; skill docs document this contract)

Updates Shortcut.Description, lark-im SKILL.md (frontmatter trigger
+ shortcut table row), and the chat-list reference doc with command
examples, the new parameter, output field documentation, and a
dedicated "Bot identity and p2p" section.

Change-Id: I637ce23b3c6ce4ec350f0ac26dbac8120761bb71
2026-05-29 15:29:37 +08:00
syh-cpdsss
0a2c3202cb fix: whiteboard skill (#1166)
Change-Id: Ib1da37c1520d7697eaee7146555185ffbc749217
2026-05-29 14:23:11 +08:00
JackZhao10086
176d452cc1 feat: add agent header support (#1158)
* feat: add agent header support
2026-05-29 13:44:15 +08:00
liangshuo-1
a2cc5e124e fix(install): detect curl version before using --ssl-revoke-best-effort (#1124)
* fix(install): detect curl version before using --ssl-revoke-best-effort

(cherry picked from commit da14737702)

* test(install): cover curl version gate and refactor for testability

Extract the version comparison out of curlSupportsSslRevokeBestEffort()
into a pure isCurlVersionSupported(output), so the >= 7.70.0 logic is unit
testable without spawning curl. Add cases for 7.55.1 / 7.69.0 / 7.70.0 /
8.x plus the unparseable and libcurl-token edge cases (the regex must read
the leading "curl X.Y.Z", not the trailing "libcurl/X.Y.Z").

Memoize the `curl --version` probe: curl's version is invariant for the
install's lifetime while download() runs once per mirror URL, so probe at
most once instead of re-spawning curl on every attempt.

---------

Co-authored-by: EllienTang <146210093+Ellien-Tang@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-05-28 22:51:16 +08:00
liangshuo-1
a2dde84158 chore(release): v1.0.43 (#1161) 2026-05-28 21:46:02 +08:00
hugang-lark
21998b9ca8 feat: support note generated event (#1159) 2026-05-28 21:10:14 +08:00
liangshuo-1
ce2abff8ae fix(config): propagate Lang across credential boundary; respect CurrentApp in priorLang (#1157)
Two issues caught in review of #1132 that the existing tests missed because
they constructed RuntimeContext/CliConfig directly, bypassing the credential
edge where the bug lives.

P1 — Lang dropped at credential boundary
  credential.Account had no Lang field, so AccountFromCliConfig and
  ToCliConfig silently dropped cfg.Lang. The production Factory builds
  CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which
  meant RuntimeContext.Lang() always returned "" in production and
  shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating
  the whole point of persisting --lang.

  Fix: add Lang i18n.Lang to Account and copy it in both directions.

  Regression test: TestFullChain_LangSurvivesProductionPath walks the
  real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount
  -> ToCliConfig) and asserts Lang survives, so any future field added
  to CliConfig forces the same audit.

P2 — priorLang ignored CurrentApp in multi-profile workspaces
  priorLang scanned all Apps and returned the first non-empty Lang. If a
  user had multiple profiles and the active one disagreed with Apps[0],
  a re-bind without --lang would silently inherit the wrong profile's
  preference.

  Fix: read multi.CurrentAppConfig("").Lang instead.

  Regression tests cover CurrentApp wins over Apps[0], single-app
  fallback, and malformed bytes.

Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
2026-05-28 20:53:15 +08:00
sammi-bytedance
893555a1b1 perf(im): parallelize reactions, thread_replies, and merge_forward fetches (#1146)
Follow-up to #1095. The reactions auto-enrichment shipped, but on busy chats the strictly-serial per-resource fetches in EnrichReactions, ExpandThreadReplies, and merge_forward expansion stretched the command's wall time above 14s — enough that wrapper agents (30–60s wall-clock budgets) saw timeouts even though the CLI itself never errored. This PR parallelizes all three with the same bounded-concurrency pattern, batches the follow-up contact-API sender resolution so it doesn't fan back out into a serial stall, and fixes two correctness bugs that surfaced during review. Scoped to convert_lib/{reactions,thread,merge,content_convert}.go + tests + the 4 shortcut Execute hooks + the reference doc.

Change-Id: I0206d10ad204382170bd42aec67f82578923736e
2026-05-28 19:25:11 +08:00
YangJunzhou-01
8d496b8a48 docs: update IM skill urgent APIs (#1153)
Add support for IM urgent messages.
Change-Id: Ide2416af6d3d47d35cfd4c60b31e2137889081c6
2026-05-28 19:22:41 +08:00
HanShaoshuai-k
01fe71d7db fix(config): allow lark-channel bind source override (#1154)
Change-Id: I406ea13e372e6bdd5f3d9d6210b04ebdf0354182
2026-05-28 18:56:36 +08:00
luozhixiong01
3b770558e5 feat: decouple --lang preference from TUI display language (#1132) 2026-05-28 18:55:40 +08:00
Kyalpha
3cd84fca90 test(drive): drop redundant CONFIG_DIR isolation in inspect Execute tests (#1121)
The six TestDriveInspectExecute_* tests set
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) but build the CLI via
cmdutil.TestFactory(t, cfg), which provides an in-memory config closure
(func() (*core.CliConfig, error) { return config, nil }) and never reads the
filesystem. Per the repo learning from PR #343, this env var should only be
set for tests exercising the real NewDefault() factory path. None of these
tests use NewDefault(), so the calls are dead and removed.

No behavior change; all TestDriveInspect* tests still pass.

Co-authored-by: kyalpha313 <kyalpha313@users.noreply.github.com>
2026-05-28 17:32:31 +08:00
YangJunzhou-01
c2e737434c fix(im): clarify messages-send dry-run chat membership (#1150)
clarify messages-send dry-run chat membership
2026-05-28 16:39:00 +08:00
zgz2048
b91f6a23f3 fix: include log_id in base attachment media errors (#1133) 2026-05-28 11:54:18 +08:00
bubbmon233
bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00
liangshuo-1
cdae999541 chore(release): v1.0.42 (#1137)
Change-Id: Id4478295cf364a01b712b7ddcd4a6cbdc264e28d
2026-05-27 20:52:24 +08:00
raistlin042
36ff632a13 fix(apps): update miaoda scopes after platform consolidation (#1127)
妙搭/spark consolidated the apps domain onto spark:app:read / spark:app:write.
The standalone spark:app:publish and spark:app.access_scope:* scopes are retired.

- +html-publish:      spark:app:publish            -> spark:app:write
- +access-scope-get:  spark:app.access_scope:read  -> spark:app:read
- +access-scope-set:  spark:app.access_scope:write -> spark:app:write

Verified against the official docs for upload_html_code_and_release,
get_app_visibility and update_app_visibility. +create/+update/+list were
already correct (spark:app:write / spark:app:read).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:51:59 +08:00
xukuncx
ab94ee9f54 feat(mail): add +draft-send shortcut for batch draft sending (#1017)
Add `lark-cli mail +draft-send` shortcut that takes one or more existing
draft IDs and sends each via POST /drafts/:draft_id/send sequentially.
Per-draft failures are isolated and aggregated into a structured output;
fatal failures (auth, permission, network, mailbox quota) abort the
entire batch immediately while recoverable failures honor --stop-on-error.

Also extend internal/output with six mail-send-specific errno constants
(LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt},
LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr.

Risk is "high-risk-write" so the framework's --yes gate applies; the
shortcut declares only the minimal mail:user_mailbox.message:send scope
to avoid asking users for permissions it does not need.
2026-05-27 18:12:41 +08:00
sammi-bytedance
30327abacb feat(im): enrich messages with reactions + output update_time (#1095)
- Pull messages now auto-call im.reactions.batch_query and attach a
  reactions block (counts + details) to each message. Stops AI from
  misjudging "user already reacted" as "no response yet" and
  re-sending duplicate reactions. Server caps queries[] at 20 per
  call, so messages are split into batches of size <= 20.
- Edited messages additionally surface update_time. The server echoes
  update_time == create_time for unedited messages too, so the field
  is only emitted when updated == true; otherwise every message
  output would look "edited". The value is read via an explicit
  string assertion + TrimSpace so empty strings are filtered properly
  (the previous `v != ""` was a no-op for non-string types).
- All four message-pulling shortcuts (+messages-mget,
  +chat-messages-list, +messages-search, +threads-messages-list) get
  a --no-reactions opt-out flag for callers that want to skip the
  extra round-trip.
- Each shortcut declares im:message.reactions:read on its
  UserScopes/BotScopes (or Scopes for the user-only search command) so
  the auth flow covers the new dependency.
- Each shortcut's --dry-run output now lists the
  reactions/batch_query call (or omits it when --no-reactions is set),
  so callers can audit the full set of API calls before execution.
- Warnings go through runtime.IO().ErrOut (forbidigo lint requires
  IOStreams over os.Stderr in shortcut code).
- Duplicate message_id inputs (e.g. mget --message-ids om_a,om_a)
  attach the reactions block to every entry while still querying the
  API only once per distinct id.
- EnrichReactions walks msg["thread_replies"] recursively, and mget/
  chat-messages-list call it after ExpandThreadReplies, so replies
  receive reactions in the same batched call as their parent message.
- When the batch_query call fails or returns per-message failures,
  the affected messages get reactions_error=true (mirroring the
  thread_replies_error flag from thread.go) so consumers can
  distinguish "fetch failed" from "no reactions exist" by reading
  stdout alone, without depending on the stderr warning channel.
- lark-im skill docs: the default-enrichment contract lives in a
  standalone references/lark-im-message-enrichment.md so the generated
  SKILL.md can't strand it on regeneration. The four read references
  and the raw reactions API reference link to it, and the template
  source skill-template/domains/im.md carries a durable pointer.

Change-Id: Ia9ea74b11945644262bb25c6503fb9b2003c6c98
2026-05-27 18:06:36 +08:00
sang-neo03
70081f62b1 feat: use description and command in affordance example schema (#1126)
Affordance examples previously carried a title plus a structured input
object mirroring the inputSchema. Replace that with a description plus a
command string holding a ready-to-run lark-cli invocation, which is what
an AI agent driving the CLI actually consumes.

No affordance data exists in the registry yet, so this only reshapes the
consuming AffordanceCase type and its tests; the data pipeline
(registry-config.yaml -> gen-registry.py -> meta_data.json) forwards the
new keys verbatim.
2026-05-27 16:08:21 +08:00
AlbertSun
17cbc13fcb refactor(auth): drop duplicate top-level user fields in status (#1128)
* opt: trim duplicate auth status info

* fix: update signals of auth status workflow
2026-05-27 16:07:21 +08:00
fangshuyu-768
e98471ce26 docs: document block anchor URLs in lark-doc skill (#1120) 2026-05-27 14:32:46 +08:00
sang-neo03
9e2be14301 feat(schema): output json spec envelope for all API commands (#1048)
* feat(schema): add envelope types and ordered properties container

* feat(schema): build meta_data.json key-order index for property ordering

* feat(schema): implement convertProperty with file/enum/range/nested handling

* feat(schema): build inputSchema with x-in / file binary / yes injection

* feat(schema): build outputSchema wrapping responseBody

* feat(schema): build _meta with scopes/risk/access_tokens normalization

* feat(schema): scaffold affordance overlay loader (PR-1 stub)

* feat(schema): wire up AssembleEnvelope main entry point

* feat(schema): parse dotted and space-separated path arguments

* feat(schema): batch envelope assembly with optional method filter

* feat(schema): implement L1-L3 envelope lint (structure/type/cross-field)

* feat(schema): measure L4 coverage and gate all envelopes through L1-L3

* feat(schema): add golden test harness with UPDATE_GOLDEN refresh

* test(schema): seed 20 golden envelopes covering edge cases

* feat(schema): output MCP envelope as default JSON, preserve pretty mode

Rewrites cmd/schema/schema.go so the default --format json branch emits
MCP-spec envelopes via schema.AssembleAll/AssembleService/AssembleEnvelope.
The legacy --format pretty branch is preserved verbatim and still uses
printServices / printResourceList / printMethodDetail.

Args max raised from 1 to 8 so the path can be supplied either as a single
dotted argument (im.reactions.list) or as space-separated segments
(im reactions list); both forms route through schema.ParsePath and produce
byte-identical output.

The completeSchemaPath function is extended to drive tab-completion for
both forms: legacy dotted prefix when len(args) == 0, and per-segment
resource/method completion when args already contains earlier segments.

BREAKING CHANGE: default JSON output shape changes from the raw meta_data
structure to an MCP envelope array/object. Existing scripts parsing the
old shape must either pin --format pretty or migrate to the new envelope
fields (name, description, inputSchema, outputSchema, _meta).

* test(schema): cover envelope JSON output, space-form path, yes injection

Replaces TestSchemaCmd_NoArgs with two variants reflecting the new default
shape: TestSchemaCmd_NoArgs_Pretty asserts the legacy "Available services"
text appears only under --format pretty, and TestSchemaCmd_NoArgs_JSON_IsArray
asserts the default JSON output parses as an envelope array with at least 180
entries.

Adds six new tests:
- TestSchemaCmd_JSONIsEnvelope: single-method output has name / description
  / inputSchema / outputSchema / _meta keys and envelope_version "1.0".
- TestSchemaCmd_SpaceSeparatedPath_EqualsDotted: dotted and space forms
  produce identical output bytes for the same command path.
- TestSchemaCmd_ServiceListIsArray: schema <service> returns a JSON array
  whose every entry's name starts with "<service> ".
- TestSchemaCmd_HighRiskYesInjection: high-risk-write commands inject
  inputSchema.properties.yes.
- TestSchemaCmd_NoYesForReadRisk: read-risk commands do not inject yes.
- TestSchemaCmd_PrettyUnchanged_KeyTextPresent: --format pretty still
  surfaces the legacy section markers (Parameters:, Response:, Identity:,
  Scopes:, CLI:).

* feat(schema): assemble envelope from embedded data only for stability

* chore(schema): lint cleanup

* fix(schema): preserve dotted resource segments in envelope name

Nested resources whose meta_data key contains a dot (e.g. chat.members,
user_mailbox.templates) were previously split on '.' and rejoined with
spaces, producing envelope names like 'im chat members bots'. AI
consumers doing name.split(' ') and feeding the result back as argv
got 'lark-cli im chat members bots' which the CLI rejects — the actual
invocation form is 'lark-cli im chat.members bots'.

Pass the dotted resource key as a single argv segment so the envelope
name 'im chat.members bots' round-trips through name.split(' ') back
to the CLI. Mirror the same convention in the golden harness so its
single-method assembly matches the live AssembleService walk.

* fix(schema): align MCP envelope output with JSON Schema 2020-12 contract

- coerce enum literals to typed JSON values (integer to int64,
  number to float64, boolean to bool) so type:"integer" fields no
  longer emit string enums; sort numeric/boolean enums while
  preserving meta_data order for string enums that carry semantic
  priority
- translate non-standard meta_data type:"list" to JSON Schema
  type:"array" with items:{} fallback when element shape is absent
  (covers the two mail attachment_ids fields)
- render inputSchema.required even when empty so consumers see a
  stable envelope shape ("[]" means no required fields, not "field
  is missing")
- reject trailing path segments in both JSON and pretty modes so
  schema im.messages.delete.foo errors instead of silently
  returning the delete method
- drop dead "list type" entry from lint_test isKnownDataInconsistency
  whitelist now that list values are translated upstream

* fix(schema): address CodeRabbit findings and stabilize CI tests

CI fix
- Replace hard-coded absolute key-order assertions in TestKeyOrderIndex_*
  and TestBuildInputSchema_* with set-membership and propagation invariants;
  the upstream meta_data API does not guarantee stable JSON key order across
  fetches, so the old tests were flaky on CI by design.
- Skip byte-level TestGoldenEnvelopes when CI=true; golden snapshots are a
  manual refresh artefact tied to a specific meta_data fetch, not a CI gate.
- Add TestMain to isolate registry-backed tests from any host ~/.lark-cli
  cache (LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) so the
  suite gives the same answer on every machine.

CodeRabbit review actionables
- EmbeddedServiceNames returns a defensive copy so callers cannot mutate
  the package-level slice and affect subsequent assembly determinism.
- coerceEnumValue is now also applied to default literals: integer fields
  no longer ship default: "500" — they ship default: 500 (same idea as the
  earlier enum coercion fix).
- options-branch string enums preserve meta_data source order, matching the
  enum-branch policy; only numeric/boolean enums get sorted.
- validatePropertyTypes now validates the array element schema itself
  (type, nested items), not only items.properties — previously a primitive
  element with an invalid type (e.g. items.type="list") slipped past lint.
- OrderedProps.MarshalJSON falls back to alphabetical key order when Map
  has entries but Order is empty, instead of silently emitting {}.

Tests pass locally and with CI=true env (simulating GitHub Actions).

* chore(schema): refresh golden envelopes after meta_data drift

Re-generated with UPDATE_GOLDEN=1 against the current meta_data.json
snapshot. The bulk of the diff is upstream noise (description wording,
enum entries, field order) which the CI snapshot diff can no longer
reasonably gate (see previous commit). Side-effects of the code fixes
in the parent commit are also captured:

  - integer-typed defaults now emit numeric literals (e.g. page_size
    default 500, not "500") thanks to coerceEnumValue
  - mail.user_mailbox.templates.create _meta.risk corrects to "write"
    (assembler already emitted "write"; the old golden was stale)

* fix(schema): address CodeRabbit round-3 review findings

- TestMain: cleanup now runs reliably. os.Exit skips deferred functions,
  so the previous defer os.RemoveAll(dir) never executed. Replace defer
  with explicit cleanup, and fail fast if MkdirTemp errors instead of
  silently running against the host cache (which defeats isolation).
- convertProperty default coercion: when the literal cannot be coerced to
  the declared type (e.g. default:"" on integer field, used by meta_data
  to mean "no default"), omit the field entirely rather than emit a
  type-mismatched default. Removes a contract violation flagged on
  im.reactions.list.json#page_size.

* feat(schema): wire affordance overlay into envelope _meta

Replace the loadAffordance stub (which always returned nil and read
from an empty embedded annotations/ directory) with parseAffordance,
which lifts the affordance block from method["affordance"]. The block
is authored under larksuite-cli-registry's registry-config.yaml in the
overrides: section and flows through gen-registry.py's deep_merge into
the embedded meta_data.json.

Simplify buildMeta signature: the service/resourcePath/method args
existed only to feed the old dotted-path lookup.

Refresh 9 golden envelopes for unrelated upstream meta_data.json drift.

* refactor(schema): drop x-in extension from inputSchema

x-in (path/query/body) was an HTTP-shape leak in a CLI-facing tool spec.
AI consumers call the CLI by name with named args — they never construct
HTTP requests directly, so the path-vs-body-vs-query distinction is the
CLI's internal concern, not part of the contract.

Execution path (cmd/service/service.go) already reads location from
meta_data.json directly, so removing x-in does not affect routing.

Drop:
- Property.XIn field
- validXIn map and the two lint rules that depend on x-in
  (L1 "top-level missing x-in" and L2 "path field must be in required")
- contains() helper, no longer referenced after the path-required rule
  went away

Refresh 20 goldens for the now-absent x-in lines.

* refactor(schema): wrap inputSchema into params/data/flags sub-objects

Replace the flat inputSchema with a 3-bucket nested structure that mirrors
the CLI's actual flag layout, so AI consumers can directly map envelope
fields to lark-cli invocation:

  inputSchema:
    properties:
      params: { ...path + query fields  }   → CLI --params JSON
      data:   { ...body fields           }   → CLI --data   JSON
      flags:  { yes: ... }                  → CLI --yes (only for high-risk-write)

Each sub-object only appears when the method has the corresponding source,
so read-only GETs have a single `params` block, body-only POSTs have a
single `data` block, etc.

The `flags` wrapper carries an explicit description marking it as a CLI
control bucket (not API fields), so AI does not confuse `yes` with a
backend parameter.

Lint:
- L2 walkForL2 helper recurses into params/data sub-objects so leaf
  invariants (format:binary on non-string, min<max, required-in-properties)
  still apply.
- L3 yes-presence check now navigates flags.properties.yes.

Refresh all 20 goldens for the new shape.

* refactor(schema): drop flags wrapper, put yes at top level alongside params/data

The flags wrapper added one extra layer for a single field. Flatten so
inputSchema.properties has three siblings:

  inputSchema:
    properties:
      params: { ...path + query    }   → CLI --params
      data:   { ...body            }   → CLI --data
      yes:    { boolean, default:false }   → CLI --yes (only when risk == high-risk-write)

`yes` description strengthened to mark it as a CLI confirmation gate
(consumed by lark-cli, not sent to the backend), so AI can still
distinguish it from API fields without needing a wrapper.

Lint L3 yes-presence check goes back to top-level Properties.Map["yes"].
Refresh 20 goldens.

* feat(schema): add `file` top-level sub-object for binary upload fields

Splits file fields out of `data` into their own sibling, so the four
top-level slots in inputSchema map 1:1 to CLI flag dispatch:

  inputSchema.properties:
    params  { path + query fields }                   → --params JSON
    data    { non-file body fields }                  → --data   JSON
    file    { type:file body fields, format:binary }  → --file <key>=<path>
    yes     boolean                                   → --yes (only when risk == high-risk-write)

Each slot is conditional: only registered when the method actually has
fields for that source. This matches the CLI's own conditional flag
registration (cmd/service/service.go:170-195), so what AI sees in the
schema is exactly what flags exist for that method.

The file sub-object carries a description explaining its semantics so AI
knows to use --file for those fields rather than embedding the binary
in --data JSON.

Refresh im.images.create golden (the only file-upload method in the
golden set).

* test(schema): cover L2 lint recursion into params/data sub-objects

Add two negative test cases that stuff bad values inside the wrapped
inputSchema sub-objects (rather than at top-level), to lock in
walkForL2's recursive coverage:

  - format:binary on a non-string field nested under params
  - sub-object Required referencing a key not in its Properties

Regression guard so future walkForL2 refactors do not silently lose
recursion and let leaf-field violations slip past lint.

* fix(schema): coerce example, aggregate nested required, fix path hint

- coerce `example` literal to the declared JSON Schema type (rename
  coerceEnumValue -> coerceLiteral, drop on coerce failure to match the
  `default` policy). Without this, integer/boolean/number fields emitted
  string examples and failed strict validators.
- aggregate child field `required:true` into the enclosing nested
  object's `required[]` (both object and array-items shapes). Previously
  only the top-level params/data sub-objects scanned `required`, so
  envelopes silently under-reported the real call contract.
- check method existence before reporting trailing-segment failure in
  both JSON and pretty `schema` paths. A typo like `schema im messages
  typo extra` now reports "Unknown method: im.messages.typo" instead of
  the misleading "Method 'typo' exists but trailing segments ..." hint.
- extract risk level constants (RiskRead / RiskWrite / RiskHighRiskWrite)
  in internal/cmdutil/risk.go; replace literal usages in schema, lint,
  and confirm helpers so the typo radius is one file.
- reconcile AssembleEnvelope docstring with implementation reality (the
  package-level currentMethodOrder + assembleMu serialize concurrent
  callers; output is deterministic per inputs).
- drop testdata/golden/ and golden_test harness. End-to-end envelope
  shape regression now relies on real CLI invocations and the existing
  property-level unit + lint coverage.

* fix(schema): emit items:{} for all typeless arrays, restore lint gate

The list→array fallback only added items:{} when the source type was
"list", leaving ~64 natively-typed array fields (e.g.
approval.instances.cc.cc_user_ids) as {type:"array"} with no items.
These violated the L1 lint rule, but TestAllEnvelopesPass skipped the
"array missing items" error as a known data inconsistency, so the MCP
tool contract was not actually lint-clean.

Relax the fallback to cover every array lacking element shape regardless
of source type, and drop the lint-test skip so the gate is hard again.
2026-05-27 12:04:01 +08:00
hugang-lark
367cfc9d06 feat: support vc,note,minute event (#1113) 2026-05-26 22:17:54 +08:00
caojie0621
e182b01f68 feat(drive): add secure label shortcuts (#985) 2026-05-26 22:06:12 +08:00
SunPeiYang996
1135fc2767 fix: remove unsupported docs fetch text format (#1109)
Change-Id: I1241ba6feede813c5bfec3e6820bc0886e39dc68
2026-05-26 21:55:42 +08:00
syh-cpdsss
68d78d5067 feat: better whiteboard svg/mermaid instructions (#1097)
* feat: better whiteboard svg/mermaid instructions

Change-Id: I615cdf405840fca6bbaea1f95a37ec655fd6aedf

* fix: PR issue

Change-Id: I0a8ee556f33f0ba65812a3d73fc9c4a5266abbcd
2026-05-26 21:18:08 +08:00
172 changed files with 17396 additions and 605 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Build output
/lark-cli
/lark-cli*
.cache/
dist/
bin/

View File

@@ -2,6 +2,68 @@
All notable changes to this project will be documented in this file.
## [v1.0.44] - 2026-05-29
### Features
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
- **agent**: Add agent header support (#1158)
### Bug Fixes
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
- **whiteboard**: Fix whiteboard skill (#1166)
## [v1.0.43] - 2026-05-28
### Features
- **event**: Support `note` generated event (#1159)
- **config**: Decouple `--lang` preference from TUI display language (#1132)
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
### Bug Fixes
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
- **config**: Allow lark-channel bind source override (#1154)
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
- **base**: Include `log_id` in attachment media errors (#1133)
### Performance
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
### Documentation
- **im**: Update IM skill urgent APIs (#1153)
## [v1.0.42] - 2026-05-27
### Features
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
- **im**: Enrich messages with reactions and output `update_time` (#1095)
- **schema**: Output JSON spec envelope for all API commands (#1048)
- **event**: Support `vc` / `note` / `minute` events (#1113)
- **drive**: Add secure label shortcuts (#985)
- **affordance**: Use description and command in affordance example schema (#1126)
### Bug Fixes
- **docs**: Remove unsupported `fetch` text format (#1109)
### Refactor
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
### Documentation
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
## [v1.0.41] - 2026-05-26
### Features
@@ -886,6 +948,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -121,7 +122,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
lang := "zh"
var lang i18n.Lang
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -177,7 +178,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
}

View File

@@ -3,6 +3,8 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
return loginMsgEn
}
return loginMsgZh

View File

@@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == "zh" && want == "turn" {
if lang == i18n.LangZhCN && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {

View File

@@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error {
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
@@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string {
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -37,8 +38,10 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation.
Force bool
Lang string
langExplicit bool // true when --lang was explicitly passed
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
@@ -55,7 +58,7 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f}
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "bind",
@@ -102,7 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -147,7 +150,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts)
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -202,16 +205,18 @@ func finalizeSource(opts *BindOptions) (string, error) {
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it.
// env already pinned it. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection("")
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
if explicit != "" {
@@ -245,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
@@ -329,7 +334,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
@@ -347,14 +352,23 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
// preferredLang resolves the language to persist: the requested value when set,
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
@@ -365,9 +379,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
if opts.Lang != "" {
appConfig.Lang = opts.Lang
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
// fallback) — scanning all apps for the first non-empty Lang would leak the
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -393,7 +421,10 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
replaced := previousConfigBytes != nil
msg := getBindMsg(opts.Lang)
// uiMsg renders human-facing TUI text (stderr success banner). Follows
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
@@ -401,7 +432,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
@@ -419,12 +454,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced,
"identity": opts.Identity,
}
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
// JSON "message" follows the effective preference on disk (appConfig.Lang),
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -461,7 +501,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
var source string
// Pre-select based on detected env signals
@@ -486,7 +526,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -508,7 +548,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
@@ -522,7 +562,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
Options(options...).
Value(&selected),
),
@@ -539,7 +579,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -591,6 +631,11 @@ func validateBindFlags(opts *BindOptions) error {
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
@@ -606,8 +651,8 @@ func validateBindFlags(opts *BindOptions) error {
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
msg := getBindMsg(opts.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string

View File

@@ -3,6 +3,8 @@
package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
@@ -84,6 +86,11 @@ type bindMsg struct {
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
}
var bindMsgZh = &bindMsg{
@@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
@@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
}
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
return bindMsgEn
}
return bindMsgZh
@@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg {
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand, lang string) string {
func brandDisplay(brand string, lang i18n.Lang) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang == "en" {
if lang.IsEnglish() {
return "Feishu"
}
return "飞书"

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -120,14 +121,229 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
if gotOpts.Lang != "" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang not passed")
}
}
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
// validated: wrong case, typos, and removed codes all exit with
// ExitValidation (code 2) and a message identifying the offending value.
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
func TestConfigBindRun_InvalidLang(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: true,
})
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
// explicit "") is unset: it neither errors nor persists a language, while a
// non-empty short code or Feishu locale both canonicalize to the same locale.
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
cases := []struct {
name string
lang string
explicit bool
wantLang i18n.Lang
}{
{"omitted", "", false, ""},
{"explicit empty", "", true, ""},
{"short code", "ja", true, i18n.LangJaJP},
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: tc.explicit,
}); err != nil {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := multi.CurrentAppConfig("")
if app == nil {
t.Fatal("no app persisted")
}
if app.Lang != tc.wantLang {
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
}
})
}
}
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
// --lang silently dropping a previously stored preference (appConfig is rebuilt
// fresh, so commitBinding must inherit the prior Lang).
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang ja): %v", err)
}
f2, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
// and silently returning a non-current profile's Lang. In a multi-profile
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := core.MultiAppConfig{
CurrentApp: "active",
Apps: []core.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangEnUS {
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
}
}
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangJaJP {
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
}
}
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
if got := priorLang([]byte("not json")); got != "" {
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
}
}
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
// "message" field against regressing to opts.Lang: when --lang is omitted on
// re-bind, the inherited preference (appConfig.Lang) must drive the message
// language and the embedded brand display — otherwise an AI agent that set
// English on first bind sees Chinese in every subsequent re-bind envelope.
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang en): %v", err)
}
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
msg, _ := envelope["message"].(string)
enMsg := getBindMsg(i18n.LangEnUS)
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
if msg != wantMsg {
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
}
}
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
func TestConfigBindRun_InvalidSource(t *testing.T) {
@@ -1474,10 +1690,14 @@ func TestGetBindMsg_En(t *testing.T) {
}
}
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
msg := getBindMsg("fr")
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
// Only zh and en TUI bundles exist; any non-English language (canonical
// locale, short code, or unrecognized value) falls back to zh.
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
msg := getBindMsg(lang)
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
}
}
}
@@ -1640,3 +1860,36 @@ func TestHasStrictBotLock(t *testing.T) {
})
}
}
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
// confirmation line: when --lang is explicit, bind prints "language preference
// set" to stderr (rendered in the TUI language, embedding the preference value).
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: "bot-only",
Lang: "en",
langExplicit: true,
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// The short --lang en is canonicalized to en_us before the confirmation
// echoes it back; the TUI language stays zh (flag mode, no picker).
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
if got := stderr.String(); !strings.Contains(got, want) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}

View File

@@ -389,10 +389,12 @@ func resolveHermesEnvPath() string {
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)

View File

@@ -4,6 +4,7 @@
package config
import (
"path/filepath"
"reflect"
"testing"
@@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -151,8 +152,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
// --lang en is canonicalized to en_us in RunE before runF captures opts.
if gotOpts.Lang != string(i18n.LangEnUS) {
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -173,14 +175,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
@@ -412,3 +482,59 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -31,9 +32,13 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
@@ -45,7 +50,7 @@ type ConfigInitOptions struct {
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "init",
@@ -63,6 +68,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil {
return err
}
@@ -77,7 +85,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
@@ -85,6 +93,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
return cmd
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
@@ -132,7 +159,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -146,7 +173,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
return saveAsOnlyApp(appId, secret, brand, lang)
var prior i18n.Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// saveAsProfile appends or updates a named profile in the config.
@@ -167,11 +200,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = lang
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
@@ -182,7 +214,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
@@ -238,7 +270,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = lang
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return core.SaveMultiAppConfig(existing)
}
@@ -283,29 +315,27 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set
// For interactive modes, prompt language selection if --lang was not explicitly set.
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
msg := getInitMsg(opts.Lang)
msg := getInitMsg(opts.UILang)
// Mode 3: Create new app directly (--new)
if opts.New {
@@ -324,6 +354,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
@@ -366,6 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
return nil
}
@@ -452,5 +484,6 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
)
type initMsg struct {
@@ -26,6 +27,10 @@ type initMsg struct {
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
}
var initMsgZh = &initMsg{
@@ -43,6 +48,7 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
}
var initMsgEn = &initMsg{
@@ -60,29 +66,27 @@ var initMsgEn = &initMsg{
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
}
func getInitMsg(lang string) *initMsg {
if lang == "en" {
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
huh.NewSelect[i18n.Lang]().
Title("Language / 语言").
Options(
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
).
Value(&lang),
),

View File

@@ -6,6 +6,8 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
}
for name, val := range fields {
if val == "" {
@@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) {
}
}
}
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -40,7 +41,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
@@ -55,6 +56,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
if err != nil {
return err
}
lang = string(langPref)
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
@@ -115,7 +122,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
@@ -51,6 +52,56 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
// short codes and Feishu locales both canonicalize to the same stored locale,
// empty stores no preference, and an unrecognized value errors.
func TestProfileAddRun_Lang(t *testing.T) {
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
}
}
})
t.Run("empty stores no preference", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
})
t.Run("invalid lang errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,7 +25,8 @@ type SchemaOptions struct {
Ctx context.Context
// Positional args
Path string
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
opts := &SchemaOptions{Factory: f}
cmd := &cobra.Command{
Use: "schema [path]",
Use: "schema [path | service resource method]",
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(1),
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
serviceName := parts[0]
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
return nil
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
parts := strings.Split(opts.Path, ".")
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", serviceName),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec, mode)
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
return nil
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
}
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
}
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var resNames []string
var names []string
for k := range resources {
resNames = append(resNames, k)
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
}
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
return nil
}
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
}
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
var names []string
for k := range methods {
mNames = append(mNames, k)
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if opts.Format == "pretty" {
printMethodDetail(out, spec, resName, methodName, method)
} else {
output.PrintJson(out, method)
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {

View File

@@ -5,6 +5,7 @@ package schema
import (
"bytes"
"encoding/json"
"strings"
"testing"
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
}
}
func TestSchemaCmd_NoArgs(t *testing.T) {
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list output")
t.Error("expected service list in pretty mode")
}
}
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{}) // default --format json
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := strings.TrimSpace(stdout.String())
if !strings.HasPrefix(out, "[") {
head := out
if len(head) > 80 {
head = head[:80]
}
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
}
var envs []map[string]interface{}
if err := json.Unmarshal([]byte(out), &envs); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(envs) < 193 {
t.Errorf("envelopes count = %d, want >= 193", len(envs))
}
}
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("name = %v, want \"im images create\"", env["name"])
}
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
if _, ok := env[key]; !ok {
t.Errorf("missing top-level key: %s", key)
}
}
meta, _ := env["_meta"].(map[string]interface{})
if meta["envelope_version"] != "1.0" {
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
}
}
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
cmd1 := NewCmdSchema(f1, nil)
cmd1.SetArgs([]string{"im", "images", "create"})
if err := cmd1.Execute(); err != nil {
t.Fatalf("space form failed: %v", err)
}
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
cmd2 := NewCmdSchema(f2, nil)
cmd2.SetArgs([]string{"im.images.create"})
if err := cmd2.Execute(); err != nil {
t.Fatalf("dotted form failed: %v", err)
}
if out1.String() != out2.String() {
t.Errorf("space and dotted forms produced different output")
}
}
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envs []map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
}
if len(envs) == 0 {
t.Fatal("expected non-empty array for service im")
}
for _, e := range envs {
name, _ := e["name"].(string)
if !strings.HasPrefix(name, "im ") {
t.Errorf("envelope name %q does not start with \"im \"", name)
}
}
}
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.messages.delete"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
is, _ := env["inputSchema"].(map[string]interface{})
props, _ := is["properties"].(map[string]interface{})
if _, ok := props["yes"]; !ok {
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
}
}
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.reactions.list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
is, _ := env["inputSchema"].(map[string]interface{})
props, _ := is["properties"].(map[string]interface{})
if _, ok := props["yes"]; ok {
t.Errorf("yes property should not appear for risk=read command")
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
const (
minutesDetailRetryDelay = 500 * time.Millisecond
minutesDetailMaxRetries = 2
)
// MinutesMinuteSourceOutput is the flattened minute source payload.
type MinutesMinuteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
type MinutesMinuteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
Title string `json:"title,omitempty" desc:"Minute title"`
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
}
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 {
SourceType string `json:"source_type"`
SourceEntityID string `json:"source_entity_id"`
} `json:"minute_source"`
} `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
}
out := &MinutesMinuteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
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,
SourceEntityID: src.SourceEntityID,
}
}
if rt != nil && out.MinuteToken != "" {
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
if rt == nil || out == nil || out.MinuteToken == "" {
return
}
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
type minuteDetailResp struct {
Data struct {
Minute struct {
Title string `json:"title"`
} `json:"minute"`
} `json:"data"`
}
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(minutesDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
continue
}
var resp minuteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
if resp.Data.Minute.Title == "" {
continue
}
out.Title = resp.Data.Minute.Title
return
}
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}
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)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessMinutesMinuteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"token": "<doc_token_001>",
"title": "产品周会的视频会议",
"note_id": "7616590025794260496"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_001",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_001>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeMinuteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.MinuteToken != "<doc_token_001>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "产品周会的视频会议" {
t.Errorf("Title = %q", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should not be nil")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_002",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989001"
},
"event": {
"minute_token": "<doc_token_004>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "7641156270787481117"
}
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.MinuteToken != "<doc_token_004>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should remain from event payload")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": "delayed title"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_retry",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_003>"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.Title != "delayed title" {
t.Errorf("Title = %q, want delayed title", out.Title)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_exhaust",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_002>"
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
}
}
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
}
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(`not json`),
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 string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out MinutesMinuteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"time"
"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) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("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() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package minutes registers Minutes-domain EventKeys.
package minutes
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
)
// Keys returns all Minutes-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMinuteGenerated,
DisplayName: "Minute generated",
Description: "Triggered when a minute has been generated",
EventType: eventTypeMinuteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
},
Process: processMinutesMinuteGenerated,
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
Scopes: []string{"minutes:minutes.basic:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
},
}
}

View File

@@ -6,13 +6,17 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted: only IM is wired up this phase.
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
vc.Keys(),
}
for _, keys := range all {
for _, k := range keys {

156
events/vc/note_generated.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
const (
vcNoteArtifactTypeNote = 1
vcNoteArtifactTypeVerbatim = 2
vcNoteDetailRetryDelay = 500 * time.Millisecond
vcNoteDetailMaxRetries = 2
vcNoteDetailNotFoundCode = 121004
)
// VCNoteSourceOutput is the flattened note source payload.
type VCNoteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Note source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// VCNoteGeneratedOutput is the flattened shape for vc.note.generated_v1.
type VCNoteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.note.generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
NoteID string `json:"note_id,omitempty" desc:"Note ID"`
NoteToken string `json:"note_token,omitempty" desc:"Generated note document token"`
VerbatimToken string `json:"verbatim_token,omitempty" desc:"Generated verbatim document token"`
NoteSource *VCNoteSourceOutput `json:"note_source,omitempty" desc:"Note source metadata"`
}
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
}
out := &VCNoteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
NoteID: envelope.Event.NoteID,
}
if out.Type == "" {
out.Type = raw.EventType
}
if rt != nil && out.NoteID != "" {
fillVCNoteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VCNoteGeneratedOutput) {
if rt == nil || out == nil || out.NoteID == "" {
return
}
path := fmt.Sprintf(pathNoteDetailFmt, validate.EncodePathSegment(out.NoteID))
type noteDetailResp struct {
Data struct {
Note struct {
Artifacts []struct {
ArtifactType int `json:"artifact_type"`
DocToken string `json:"doc_token"`
} `json:"artifacts"`
NoteSource struct {
SourceEntityID string `json:"source_entity_id"`
SourceType string `json:"source_type"`
} `json:"note_source"`
} `json:"note"`
} `json:"data"`
}
for attempt := 0; attempt <= vcNoteDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(vcNoteDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
if isLarkCode(err, vcNoteDetailNotFoundCode) {
continue
}
return
}
var resp noteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
var noteToken, verbatimToken string
for _, artifact := range resp.Data.Note.Artifacts {
switch artifact.ArtifactType {
case vcNoteArtifactTypeNote:
if noteToken == "" {
noteToken = artifact.DocToken
}
case vcNoteArtifactTypeVerbatim:
if verbatimToken == "" {
verbatimToken = artifact.DocToken
}
}
}
if noteToken == "" && verbatimToken == "" {
continue
}
if noteToken != "" {
out.NoteToken = noteToken
}
if verbatimToken != "" {
out.VerbatimToken = verbatimToken
}
if src := resp.Data.Note.NoteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.NoteSource = &VCNoteSourceOutput{
SourceType: src.SourceType,
SourceEntityID: src.SourceEntityID,
}
}
return
}
}
func isLarkCode(err error, code int) bool {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
}
return false
}

View File

@@ -0,0 +1,328 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:note:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCNoteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "note_doc_token"},
{"artifact_type": 2, "doc_token": "verbatim_doc_token"}
],
"note_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_001",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040898"
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != "/open-apis/vc/v1/notes/6943848821689040898" {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeNoteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_note_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.NoteID != "6943848821689040898" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "note_doc_token" {
t.Errorf("NoteToken = %q", out.NoteToken)
}
if out.VerbatimToken != "verbatim_doc_token" {
t.Errorf("VerbatimToken = %q", out.VerbatimToken)
}
if out.NoteSource == nil {
t.Fatal("NoteSource should not be nil")
}
if out.NoteSource.SourceType != "meeting" || out.NoteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("NoteSource = %+v", out.NoteSource)
}
}
func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathNoteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeNoteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathNoteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeNoteGenerated)
}
func TestProcessVCNoteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_002",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989001"
},
"event": {
"note_id": "6943848821689040999"
}
}`)
if called != 1 {
t.Fatalf("detail API called %d times, want 1", called)
}
if out.NoteID != "6943848821689040999" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty", out.NoteToken, out.VerbatimToken)
}
if out.NoteSource != nil {
t.Errorf("NoteSource = %+v, want nil", out.NoteSource)
}
}
func TestProcessVCNoteGenerated_EmptyTokensRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "delayed_note_token"},
{"artifact_type": 2, "doc_token": "delayed_verbatim_token"}
],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_retry",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040empty"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.NoteToken != "delayed_note_token" {
t.Errorf("NoteToken = %q, want delayed_note_token", out.NoteToken)
}
if out.VerbatimToken != "delayed_verbatim_token" {
t.Errorf("VerbatimToken = %q, want delayed_verbatim_token", out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_EmptyTokensExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_exhaust",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040emptyex"
}
}`)
wantCalls := 1 + vcNoteDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty after exhausted retries", out.NoteToken, out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(`not json`),
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 string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCNoteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCNoteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
type VCParticipantMeetingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the 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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingEndedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
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

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
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)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_001",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingEnded(t, payload)
if out.Type != eventTypeMeetingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_end_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1608725989000" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.MeetingID != "6911188411934433028" {
t.Errorf("MeetingID = %q", out.MeetingID)
}
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
}
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
}
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
}
}
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_002",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingEnded(t, payload)
if out.StartTime != "" || out.EndTime != "" {
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
}
}
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(`not json`),
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 string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", 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")
if !ok {
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
}
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCParticipantMeetingEndedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

33
events/vc/preconsume.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"time"
"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) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("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() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

61
events/vc/register.go Normal file
View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package vc registers VC-domain EventKeys.
package vc
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",
Description: "Triggered when a meeting the current user participates in has ended",
EventType: eventTypeMeetingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
},
Process: processVCParticipantMeetingEnded,
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
},
{
Key: eventTypeNoteGenerated,
DisplayName: "Note generated",
Description: "Triggered when a note has been generated",
EventType: eventTypeNoteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})},
},
Process: processVCNoteGenerated,
PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
Scopes: []string{"vc:note:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}

View File

@@ -205,14 +205,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
attachStreamLogID(err, resp.Header)
return nil, err
}
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
attachStreamLogID(err, resp.Header)
return nil, err
}
return resp, nil
}
func attachStreamLogID(err *output.ExitError, header http.Header) {
if err == nil || err.Detail == nil {
return
}
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID == "" {
return
}
err.Detail.Detail = map[string]any{"log_id": logID}
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client_test
import (
"context"
"errors"
"net/http"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
factory, _, _, reg := cmdutil.TestFactory(t, config)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/medias/file_token/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
Headers: http.Header{
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
},
})
client, err := factory.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
if detail["log_id"] != "202605270003" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
}

View File

@@ -34,7 +34,7 @@ func RequireConfirmation(action string) error {
Message: fmt.Sprintf("%s requires confirmation", action),
Hint: "add --yes to confirm",
Risk: &output.RiskDetail{
Level: "high-risk-write",
Level: RiskHighRiskWrite,
Action: action,
},
},

27
internal/cmdutil/lang.go Normal file
View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
// and profile so every entry point honors one contract. Empty is unset (no-op);
// a non-empty value must resolve via i18n.Parse or it errors.
func ParseLangFlag(raw string) (i18n.Lang, error) {
if raw == "" {
return "", nil
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", output.ErrValidation(
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", "))
}
return lang, nil
}

View File

@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
const riskLevelAnnotationKey = "risk_level"
// Risk level constants — the three-tier convention used across the CLI.
// Use these in place of string literals so the typo radius is one place,
// not every call site.
const (
RiskRead = "read"
RiskWrite = "write"
RiskHighRiskWrite = "high-risk-write"
)
// SetRisk stores a command's static risk level on cobra annotations so the
// help renderer (cmd/root.go) can surface a Risk: line without importing
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
// | "high-risk-write". Framework-level confirmation gating only acts on
// "high-risk-write".
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
// acts on RiskHighRiskWrite.
func SetRisk(cmd *cobra.Command, level string) {
if level == "" {
return

View File

@@ -6,15 +6,18 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/envvars"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -24,6 +27,7 @@ const (
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
SourceValue = "lark-cli"
@@ -36,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 256
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -43,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -50,6 +75,9 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h
}

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -260,3 +262,134 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
}
}
// ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentTraceHeaderWhenEnvSet(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-xyz-789")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-xyz-789" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentTrace, v, "trace-xyz-789")
}
}
func TestBaseSecurityHeaders_AgentTraceTrimmedWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-trim" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q (whitespace trimmed)", HeaderAgentTrace, v, "trace-trim")
}
}
func TestBaseSecurityHeaders_AgentTraceOnlyWhitespace_Skipped(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for whitespace-only value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsCRLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for CR/LF value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for LF value", HeaderAgentTrace, v)
}
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -41,7 +42,7 @@ type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
Lang i18n.Lang `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
@@ -159,6 +160,7 @@ type CliConfig struct {
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
@@ -264,6 +266,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
)
@@ -115,3 +116,45 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
}
}
// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the
// production Factory uses (factory_default.go Phase 3): disk → multi config →
// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets
// dropped at the credential boundary (as it would when Account lacks the field),
// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream
// consumers (mail signature, etc.) silently fall back to defaults — defeating
// the whole point of persisting --lang.
func TestFullChain_LangSurvivesProductionPath(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cfg_app",
AppSecret: core.PlainSecret("cfg_secret"),
Brand: core.BrandFeishu,
Lang: i18n.LangJaJP,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
acct, err := defaultAcct.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.Lang != i18n.LangJaJP {
t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP)
}
cfg := acct.ToCliConfig()
if cfg == nil {
t.Fatal("ToCliConfig() = nil")
}
if cfg.Lang != i18n.LangJaJP {
t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP)
}
}

View File

@@ -10,6 +10,7 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// Account is the credential-layer view of the active runtime account.
@@ -23,6 +24,7 @@ type Account struct {
DefaultAs core.Identity
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8
}
@@ -65,6 +67,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
DefaultAs: cfg.DefaultAs,
UserOpenId: cfg.UserOpenId,
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
}
}
@@ -82,6 +85,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestTokenTypeString(t *testing.T) {
@@ -53,6 +54,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
DefaultAs: "user",
UserOpenId: "ou_123",
UserName: "alice",
Lang: i18n.LangJaJP,
SupportedIdentities: 3,
}
@@ -63,6 +65,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
}
if acct.Lang != cfg.Lang {
t.Fatalf("AccountFromCliConfig().Lang = %q, want %q", acct.Lang, cfg.Lang)
}
roundtrip := acct.ToCliConfig()
if roundtrip == nil {
@@ -71,6 +76,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
}
if roundtrip.Lang != cfg.Lang {
t.Fatalf("ToCliConfig().Lang = %q, want %q (production Factory path reads Lang via this conversion)", roundtrip.Lang, cfg.Lang)
}
roundtrip.AppID = "mutated-cli"
acct.AppID = "mutated-account"

View File

@@ -18,4 +18,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
)

76
internal/i18n/lang.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset.
type Lang string
const (
LangZhCN Lang = "zh_cn"
LangEnUS Lang = "en_us"
LangJaJP Lang = "ja_jp"
LangKoKR Lang = "ko_kr"
LangFrFR Lang = "fr_fr"
LangDeDE Lang = "de_de"
LangEsES Lang = "es_es"
LangItIT Lang = "it_it"
LangRuRU Lang = "ru_ru"
LangPtBR Lang = "pt_br"
LangThTH Lang = "th_th"
LangViVN Lang = "vi_vn"
LangIdID Lang = "id_id"
LangMsMY Lang = "ms_my"
)
type langEntry struct {
Code Lang // canonical Feishu locale
Short string // ISO 639-1 code, also accepted as input shorthand
}
// catalog is the single source of truth; order drives --help and error listing.
var catalog = []langEntry{
{LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, {LangKoKR, "ko"},
{LangFrFR, "fr"}, {LangDeDE, "de"}, {LangEsES, "es"}, {LangItIT, "it"},
{LangRuRU, "ru"}, {LangPtBR, "pt"}, {LangThTH, "th"}, {LangViVN, "vi"},
{LangIdID, "id"}, {LangMsMY, "ms"},
}
// find matches a short code or Feishu locale against the catalog (case-sensitive).
func find(s string) (langEntry, bool) {
for _, e := range catalog {
if string(e.Code) == s || e.Short == s {
return e, true
}
}
return langEntry{}, false
}
// Parse resolves a short code or Feishu locale to its canonical Lang.
// "" and unrecognized values return ("", false).
func Parse(s string) (Lang, bool) {
e, ok := find(s)
return e.Code, ok
}
// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us"
// and legacy "en").
func (l Lang) IsEnglish() bool {
e, _ := find(string(l))
return e.Code == LangEnUS
}
// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown.
func (l Lang) Base() string {
e, _ := find(string(l))
return e.Short
}
// Codes lists the canonical locales, for --help and error messages.
func Codes() []string {
out := make([]string, len(catalog))
for i, e := range catalog {
out[i] = string(e.Code)
}
return out
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
import "testing"
func TestParse(t *testing.T) {
tests := []struct {
in string
want Lang
wantOK bool
}{
{"zh", LangZhCN, true}, // short code
{"zh_cn", LangZhCN, true}, // canonical locale
{"en", LangEnUS, true}, // short code
{"en_us", LangEnUS, true}, // canonical locale
{"ja", LangJaJP, true}, // short code
{"pt", LangPtBR, true}, // pt → pt_br, not pt_pt
{"ms", LangMsMY, true}, // ms → ms_my
{"", "", false}, // unset
{"ZH", "", false}, // case-sensitive
{"zh-CN", "", false}, // hyphen form not accepted
{"zh_CN", "", false}, // case-sensitive region
{"ar", "", false}, // not in the supported set
{"xx", "", false}, // unknown
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, ok := Parse(tt.in)
if got != tt.want || ok != tt.wantOK {
t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestIsEnglish(t *testing.T) {
tests := []struct {
lang Lang
want bool
}{
{LangEnUS, true},
{Lang("en"), true}, // legacy short value on disk stays robust
{LangZhCN, false},
{LangJaJP, false},
{Lang("zh"), false},
{Lang(""), false}, // unset → not English (zh bundle)
{Lang("garbage"), false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.IsEnglish(); got != tt.want {
t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want)
}
})
}
}
func TestBase(t *testing.T) {
tests := []struct {
lang Lang
want string
}{
{LangEnUS, "en"},
{LangZhCN, "zh"},
{LangJaJP, "ja"},
{Lang("en"), "en"}, // legacy short value
{Lang("zh"), "zh"},
{Lang(""), ""}, // unset
{Lang("garbage"), ""}, // unknown
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.Base(); got != tt.want {
t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestCodes(t *testing.T) {
codes := Codes()
if len(codes) != 14 {
t.Fatalf("len(Codes()) = %d, want 14", len(codes))
}
if codes[0] != "zh_cn" {
t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn")
}
// Every code must round-trip through Parse to itself (canonical).
for _, c := range codes {
if got, ok := Parse(c); !ok || string(got) != c {
t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c)
}
}
}

View File

@@ -66,6 +66,19 @@ const (
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because ErrAPI preserves Detail.Code exactly as returned by the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
// legacyHints supplies the per-code actionable hint string for the legacy

View File

@@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
}
}
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
}{
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.got != tt.want {
t.Fatalf("code=%d, want %d", tt.got, tt.want)
}
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create

View File

@@ -22,6 +22,64 @@ var registryFS embed.FS
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
var embeddedMetaJSON []byte
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
// that need to parse key order or other JSON-level structure not exposed by
// LoadFromMeta (which loses map insertion order).
func EmbeddedMetaJSON() []byte {
return embeddedMetaJSON
}
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
)
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
// without touching mergedServices. Safe to call multiple times (sync.Once).
func parseEmbeddedServices() {
embeddedParseOnce.Do(func() {
embeddedServicesMap = make(map[string]map[string]interface{})
if len(embeddedMetaJSON) == 0 {
return
}
var wrapper struct {
Services []map[string]interface{} `json:"services"`
}
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
return
}
for _, svc := range wrapper.Services {
name, _ := svc["name"].(string)
if name == "" {
continue
}
embeddedServicesMap[name] = svc
}
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
for name := range embeddedServicesMap {
embeddedServiceNames = append(embeddedServiceNames, name)
}
sort.Strings(embeddedServiceNames)
})
}
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
// Bypasses remote overlay — used for deterministic envelope output.
func EmbeddedSpec(serviceName string) map[string]interface{} {
parseEmbeddedServices()
return embeddedServicesMap[serviceName]
}
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
// Returns a defensive copy — callers must not mutate the package-level slice.
func EmbeddedServiceNames() []string {
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
return out
}
var (
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names

View File

@@ -0,0 +1,874 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"bytes"
"encoding/json"
"sort"
"strconv"
"sync"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/registry"
)
// MethodKeyOrder records the natural meta_data.json key order for one method's
// parameters / requestBody / responseBody. Nested object key orders are stored
// under NestedKeys, keyed by dotted path from the method root
// (e.g. "responseBody.items.properties").
type MethodKeyOrder struct {
Parameters []string
RequestBody []string
ResponseBody []string
NestedKeys map[string][]string
}
var (
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
keyOrderInitOnce sync.Once
)
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
// or nil if the method is not in the embedded data (e.g. remote-cached).
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
keyOrderInitOnce.Do(buildKeyOrderIndex)
if keyOrderIndex == nil {
return nil
}
dotted := dottedPath(service, resourcePath, method)
return keyOrderIndex[dotted]
}
func dottedPath(service string, resourcePath []string, method string) string {
var buf bytes.Buffer
buf.WriteString(service)
for _, r := range resourcePath {
buf.WriteByte('.')
buf.WriteString(r)
}
buf.WriteByte('.')
buf.WriteString(method)
return buf.String()
}
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
// and recording each map's key insertion order via json.Decoder.Token().
func buildKeyOrderIndex() {
raw := registry.EmbeddedMetaJSON()
if len(raw) == 0 {
return
}
keyOrderIndex = make(map[string]*MethodKeyOrder)
dec := json.NewDecoder(bytes.NewReader(raw))
// Top-level: { "services": [...], "version": "..." }
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
if key != "services" {
skipValue(dec)
continue
}
if !expectDelim(dec, '[') {
return
}
for dec.More() {
parseService(dec)
}
// closing ]
_, _ = dec.Token()
}
}
// parseService consumes one service object inside services[].
// meta_data.json may emit "resources" before "name", so we first capture both
// raw fields, then walk resources with the resolved service name.
func parseService(dec *json.Decoder) {
if !expectDelim(dec, '{') {
return
}
var serviceName string
var resourcesRaw json.RawMessage
for dec.More() {
key, _ := readKey(dec)
switch key {
case "name":
tok, _ := dec.Token()
if s, ok := tok.(string); ok {
serviceName = s
}
case "resources":
if err := dec.Decode(&resourcesRaw); err != nil {
skipValue(dec)
}
default:
skipValue(dec)
}
}
_, _ = dec.Token() // closing }
if serviceName != "" && len(resourcesRaw) > 0 {
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
parseResources(subDec, serviceName, nil)
}
}
// parseResources walks a resources map (resName -> resource object).
// resourcePath is the accumulated path of parent resources (for nested resources).
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
resName, _ := readKey(dec)
parseResourceObj(dec, service, append(resourcePath, resName))
}
_, _ = dec.Token()
}
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
// recurse into nested resources via "resources" key if present.
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "methods":
parseMethods(dec, service, resourcePath)
case "resources":
parseResources(dec, service, resourcePath)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
}
// parseMethods consumes the methods map (methodName -> method object).
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
methodName, _ := readKey(dec)
mko := parseMethod(dec)
dotted := dottedPath(service, resourcePath, methodName)
keyOrderIndex[dotted] = mko
}
_, _ = dec.Token()
}
// parseMethod consumes one method object and records key orders.
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
if !expectDelim(dec, '{') {
return mko
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "parameters":
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
case "requestBody":
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
case "responseBody":
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
return mko
}
// recordObjectKeysRecursive consumes an object and records the top-level key
// order. It also recurses into each child's "properties" submap, recording
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
// in order.
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
if !expectDelim(dec, '{') {
return nil
}
var order []string
for dec.More() {
key, _ := readKey(dec)
order = append(order, key)
// Each child value is itself an object; we want its nested "properties" order if present.
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
}
_, _ = dec.Token()
if prefix != "" && len(order) > 0 {
nestedKeys[prefix] = order
}
return order
}
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
// if it contains "properties": {...}, recursively records that submap's order.
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
tok, err := dec.Token()
if err != nil {
return
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
// Not an object — skip the rest of the value
skipValueAfterToken(dec, tok)
return
}
for dec.More() {
fieldKey, _ := readKey(dec)
if fieldKey == "properties" {
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
} else {
skipValue(dec)
}
}
_, _ = dec.Token()
}
// --- json.Decoder helpers ---
func expectDelim(dec *json.Decoder, want json.Delim) bool {
tok, err := dec.Token()
if err != nil {
return false
}
delim, ok := tok.(json.Delim)
return ok && delim == want
}
func readKey(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
s, _ := tok.(string)
return s, nil
}
// skipValue consumes the next complete value (scalar, object, or array).
func skipValue(dec *json.Decoder) {
tok, err := dec.Token()
if err != nil {
return
}
skipValueAfterToken(dec, tok)
}
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
delim, ok := tok.(json.Delim)
if !ok {
return
}
// We started inside a container of type `delim` ({ or [) and must eat
// tokens until that container closes, tracking nested containers of any
// kind. depth counts how many open containers we are currently inside.
_ = delim
depth := 1
for depth > 0 {
t, err := dec.Token()
if err != nil {
return
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
}
// coerceLiteral converts a meta_data literal (default / enum / example) to
// the JSON Schema type declared by the field (integer/number/boolean/string).
// meta_data stores every literal as a string, so without coercion an
// `integer` field would emit string literals and fail any standard validator.
// Already-typed values pass through unchanged. Returns (value, true) on
// success, or (nil, false) when the literal cannot be coerced (caller should
// drop it).
func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) {
s, isStr := raw.(string)
if !isStr {
// Already typed (e.g. meta_data emitted a JSON number/bool directly).
return raw, true
}
switch fieldType {
case "integer":
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v, true
}
return nil, false
case "number":
if v, err := strconv.ParseFloat(s, 64); err == nil {
return v, true
}
return nil, false
case "boolean":
switch s {
case "true":
return true, true
case "false":
return false, true
}
return nil, false
default: // "string", "" (nested objects), or unknown
return s, true
}
}
// sortEnum sorts an enum slice in-place using a comparator appropriate for
// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather
// than the lexicographic [1, 10, 2].
func sortEnum(fieldType string, vals []interface{}) {
sort.SliceStable(vals, func(i, j int) bool {
switch fieldType {
case "integer":
ai, _ := vals[i].(int64)
bi, _ := vals[j].(int64)
return ai < bi
case "number":
af, _ := vals[i].(float64)
bf, _ := vals[j].(float64)
return af < bf
case "boolean":
ab, _ := vals[i].(bool)
bb, _ := vals[j].(bool)
return !ab && bb // false < true
default:
as, _ := vals[i].(string)
bs, _ := vals[j].(string)
return as < bs
}
})
}
// convertProperty recursively converts one meta_data field map into a Property.
// nestedPath is the dotted lookup key into the current method's NestedKeys map
// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested
// lookup needed.
func convertProperty(field map[string]interface{}, nestedPath string) Property {
var p Property
rawType, _ := field["type"].(string)
switch rawType {
case "file":
p.Type = "string"
p.Format = "binary"
case "list":
// meta_data uses non-standard "list" on a couple of fields;
// translate to JSON Schema "array" so validators accept it.
p.Type = "array"
default:
p.Type = rawType
}
if s, ok := field["description"].(string); ok {
p.Description = s
}
if v, ok := field["default"]; ok {
// Coerce default literal to match the declared JSON Schema type so
// validators do not reject e.g. {type:"integer", default:"500"}.
// When coercion fails (e.g. default:"" on an integer field, which
// meta_data uses to mean "no default"), omit the field entirely
// instead of emitting a type-mismatched default — the result is a
// missing `default` key rather than a contract violation.
if coerced, ok := coerceLiteral(p.Type, v); ok {
p.Default = coerced
}
}
if v, ok := field["example"]; ok {
// meta_data stores examples as strings even when the field is integer/
// boolean/number; coerce to the declared type so downstream validators
// accept the envelope. Drop on coerce failure (same policy as default).
if coerced, ok := coerceLiteral(p.Type, v); ok {
p.Example = coerced
}
}
// min / max are stored as strings in meta_data; parse on best-effort.
if minStr, ok := field["min"].(string); ok && minStr != "" {
if v, err := strconv.ParseFloat(minStr, 64); err == nil {
p.Minimum = &v
}
}
if maxStr, ok := field["max"].(string); ok && maxStr != "" {
if v, err := strconv.ParseFloat(maxStr, 64); err == nil {
p.Maximum = &v
}
}
// enum: prefer existing "enum" array; else extract from options[].value.
// Values are typed per p.Type so integer fields get integer enums, etc.
// (JSON Schema 2020-12 requires enum value types to match the declared
// type — meta_data stores everything as strings.)
if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 {
for _, e := range enumRaw {
if v, ok := coerceLiteral(p.Type, e); ok {
p.Enum = append(p.Enum, v)
}
}
// Numeric/boolean enums get sorted (no inherent meaning in meta_data
// order); string enums keep meta_data order, which sometimes carries
// semantic priority (e.g. image_type ["message","avatar"]).
if p.Type != "string" && p.Type != "" {
sortEnum(p.Type, p.Enum)
}
} else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 {
seen := make(map[string]bool)
for _, o := range optsRaw {
om, ok := o.(map[string]interface{})
if !ok {
continue
}
raw, ok := om["value"].(string)
if !ok || seen[raw] {
continue
}
seen[raw] = true
if v, ok := coerceLiteral(p.Type, raw); ok {
p.Enum = append(p.Enum, v)
}
}
// Same policy as the `enum` branch: numeric/boolean enums get sorted
// (no semantic meaning in source order); string enums keep meta_data
// order, which may carry semantic priority.
if p.Type != "string" && p.Type != "" {
sortEnum(p.Type, p.Enum)
}
}
// nested properties: recurse
if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 {
nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath)
if p.Type == "array" {
// meta_data quirk: array element schema is wrapped in "properties".
// Unfold into Items: { type: "object", properties: <nested> }
p.Items = &Property{
Type: "object",
Properties: nested,
Required: nestedRequired,
}
// Property.Properties stays nil for arrays
} else {
if p.Type == "" {
p.Type = "object" // infer
}
p.Properties = nested
p.Required = nestedRequired
}
}
// array items fallback: emit `items: {}` (any schema) for every array that
// meta_data does not describe an element shape for — whether it arrived as
// "list" or natively as "array". Without this, typeless arrays (e.g. arrays
// of bare ID strings) violate the L1 lint rule and are not JSON Schema valid
// for consumers that require `items`.
if p.Type == "array" && p.Items == nil {
p.Items = &Property{}
}
return p
}
// buildOrderedProps converts a map[string]interface{} of field specs into an
// OrderedProps plus the alphabetized list of child keys marked `required:true`
// in meta_data. Callers attach that list to the enclosing object's `required`,
// so nested objects faithfully report their call contract (top-level required
// is handled separately by buildInputSchema).
func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) {
op := &OrderedProps{Map: make(map[string]Property, len(raw))}
var required []string
keys := orderedKeys(raw, nestedPath)
for _, k := range keys {
fieldRaw, _ := raw[k].(map[string]interface{})
op.Order = append(op.Order, k)
op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties")
if req, _ := fieldRaw["required"].(bool); req {
required = append(required, k)
}
}
sort.Strings(required)
return op, required
}
// currentMethodOrder is the per-method key-order context used by orderedKeys.
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
var currentMethodOrder *MethodKeyOrder
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
// or carries no populated subfields.
//
// Affordance is authored in larksuite-cli-registry's registry-config.yaml under
// overrides.<resource>.<method>.affordance and flows through gen-registry.py's
// deep_merge into the embedded meta_data.json.
func parseAffordance(raw interface{}) *Affordance {
if raw == nil {
return nil
}
b, err := json.Marshal(raw)
if err != nil {
return nil
}
var a Affordance
if err := json.Unmarshal(b, &a); err != nil {
return nil
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return nil
}
return &a
}
// convertAccessTokens translates from_meta accessTokens (uses "tenant") into
// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically.
// Unknown tokens are dropped. Returns an empty slice for nil/empty input.
func convertAccessTokens(raw []interface{}) []string {
seen := make(map[string]bool)
for _, t := range raw {
s, ok := t.(string)
if !ok {
continue
}
switch s {
case "tenant":
seen["bot"] = true
case "user":
seen["user"] = true
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
// buildMeta produces the _meta extension namespace.
func buildMeta(method map[string]interface{}) *Meta {
m := &Meta{
EnvelopeVersion: "1.0",
RequiredScopes: []string{}, // never nil for stable JSON
}
if scopesRaw, ok := method["scopes"].([]interface{}); ok {
for _, s := range scopesRaw {
if str, ok := s.(string); ok {
m.Scopes = append(m.Scopes, str)
}
}
}
if rsRaw, ok := method["requiredScopes"].([]interface{}); ok {
for _, s := range rsRaw {
if str, ok := s.(string); ok {
m.RequiredScopes = append(m.RequiredScopes, str)
}
}
}
atRaw, _ := method["accessTokens"].([]interface{})
m.AccessTokens = convertAccessTokens(atRaw)
m.Danger, _ = method["danger"].(bool)
if risk, _ := method["risk"].(string); risk != "" {
m.Risk = risk
} else {
m.Risk = cmdutil.RiskRead
}
if docURL, _ := method["docUrl"].(string); docURL != "" {
m.DocURL = docURL
}
m.Affordance = parseAffordance(method["affordance"])
return m
}
// buildInputSchema produces the inputSchema for one API method.
//
// Top-level shape:
//
// { type: object,
// required: [<"params" if any param required>, <"data" if any body required>],
// properties: {
// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters
// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody
// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write"
// } }
//
// The params / data wrapping mirrors the CLI's actual flag layout:
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
// can pluck inputSchema.properties.params and pass it verbatim to --params.
//
// Caller must set currentMethodOrder for property-order preservation.
func buildInputSchema(method map[string]interface{}) *InputSchema {
is := &InputSchema{
Type: "object",
Required: []string{}, // never nil — stable envelope shape
Properties: &OrderedProps{Map: make(map[string]Property)},
}
// Build the "params" sub-object from method.parameters (path + query).
paramsRaw, _ := method["parameters"].(map[string]interface{})
paramsProps := &OrderedProps{Map: make(map[string]Property)}
var paramsRequired []string
for _, k := range orderedKeys(paramsRaw, "parameters") {
field, _ := paramsRaw[k].(map[string]interface{})
prop := convertProperty(field, "parameters."+k+".properties")
paramsProps.Order = append(paramsProps.Order, k)
paramsProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
paramsRequired = append(paramsRequired, k)
}
}
if len(paramsProps.Order) > 0 {
sort.Strings(paramsRequired)
is.Properties.Order = append(is.Properties.Order, "params")
is.Properties.Map["params"] = Property{
Type: "object",
Required: paramsRequired,
Properties: paramsProps,
}
if len(paramsRequired) > 0 {
is.Required = append(is.Required, "params")
}
}
// Split method.requestBody into two buckets:
// - data: non-file body fields → corresponds to CLI --data JSON
// - file: type:file body fields → corresponds to CLI --file <key>=<path>
// File fields are kept *out* of `data` so the schema mirrors the actual
// CLI flag dispatch: --file owns one wire format (multipart upload),
// --data owns the rest (JSON body).
bodyRaw, _ := method["requestBody"].(map[string]interface{})
dataProps := &OrderedProps{Map: make(map[string]Property)}
fileProps := &OrderedProps{Map: make(map[string]Property)}
var dataRequired []string
var fileRequired []string
for _, k := range orderedKeys(bodyRaw, "requestBody") {
field, _ := bodyRaw[k].(map[string]interface{})
prop := convertProperty(field, "requestBody."+k+".properties")
isFile := false
if t, _ := field["type"].(string); t == "file" {
isFile = true
}
if isFile {
fileProps.Order = append(fileProps.Order, k)
fileProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
fileRequired = append(fileRequired, k)
}
} else {
dataProps.Order = append(dataProps.Order, k)
dataProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
dataRequired = append(dataRequired, k)
}
}
}
if len(dataProps.Order) > 0 {
sort.Strings(dataRequired)
is.Properties.Order = append(is.Properties.Order, "data")
is.Properties.Map["data"] = Property{
Type: "object",
Required: dataRequired,
Properties: dataProps,
}
if len(dataRequired) > 0 {
is.Required = append(is.Required, "data")
}
}
if len(fileProps.Order) > 0 {
sort.Strings(fileRequired)
is.Properties.Order = append(is.Properties.Order, "file")
is.Properties.Map["file"] = Property{
Type: "object",
Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.",
Required: fileRequired,
Properties: fileProps,
}
if len(fileRequired) > 0 {
is.Required = append(is.Required, "file")
}
}
// high-risk-write injects a top-level `yes` confirmation flag — sibling
// of params/data. It is a CLI gate (consumed by lark-cli, not sent to
// the backend), not an API field.
if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite {
is.Properties.Order = append(is.Properties.Order, "yes")
falseVal := false
is.Properties.Map["yes"] = Property{
Type: "boolean",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
}
// yes is intentionally NOT added to top-level Required; the gate is
// enforced semantically (yes==true) by the CLI, not structurally.
}
sort.Strings(is.Required) // alphabetical
return is
}
// buildOutputSchema produces the outputSchema for one API method.
func buildOutputSchema(method map[string]interface{}) *OutputSchema {
os := &OutputSchema{
Type: "object",
Properties: &OrderedProps{Map: make(map[string]Property)},
}
respRaw, _ := method["responseBody"].(map[string]interface{})
for _, k := range orderedKeys(respRaw, "responseBody") {
field, _ := respRaw[k].(map[string]interface{})
os.Properties.Order = append(os.Properties.Order, k)
os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties")
}
return os
}
// assembleMu serializes AssembleEnvelope calls so that the package-level
// currentMethodOrder pointer is safe for concurrent callers.
var assembleMu sync.Mutex
// AssembleEnvelope is the main entry point: takes a service / resource path /
// method name plus its meta_data spec, and produces a fully assembled MCP
// envelope. Output is fully determined by inputs (same arguments → same
// envelope), but assembly briefly publishes the per-method key-order context
// through the package-level currentMethodOrder so orderedKeys can reach it
// without threading it through every helper. assembleMu serializes that
// publish, which is why concurrent callers are still safe — they queue
// rather than run in parallel.
//
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
assembleMu.Lock()
defer assembleMu.Unlock()
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
defer func() { currentMethodOrder = nil }()
name := serviceName
for _, r := range resourcePath {
name += " " + r
}
name += " " + methodName
desc, _ := method["description"].(string)
return Envelope{
Name: name,
Description: desc,
InputSchema: buildInputSchema(method),
OutputSchema: buildOutputSchema(method),
Meta: buildMeta(method),
}
}
// MethodFilter is an optional predicate used by AssembleService and
// AssembleAll to filter methods (e.g. by access token for strict mode).
// Pass nil to include all methods.
type MethodFilter func(method map[string]interface{}) bool
// AssembleService assembles all methods under one service into a sorted
// envelope slice (sorted by Envelope.Name ascending).
func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope {
if spec == nil {
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
var out []Envelope
walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) {
if filter != nil && !filter(method) {
return
}
out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method))
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// AssembleAll assembles every embedded service into one big sorted slice.
// Uses embedded data only (bypasses remote overlay) so envelope output is
// deterministic across machines (CI vs dev vs different user brands).
func AssembleAll(filter MethodFilter) []Envelope {
var out []Envelope
for _, svc := range registry.EmbeddedServiceNames() {
spec := registry.EmbeddedSpec(svc)
out = append(out, AssembleService(svc, spec, filter)...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// walkMethods recursively walks resources -> methods, calling visit for each
// terminal method. It supports nested resources via the optional "resources"
// key inside a resource value (matches meta_data.json structure).
func walkMethods(resources map[string]interface{}, parentPath []string,
visit func(resourcePath []string, methodName string, method map[string]interface{})) {
for resName, resRaw := range resources {
resMap, ok := resRaw.(map[string]interface{})
if !ok {
continue
}
curPath := append(append([]string(nil), parentPath...), resName)
if methods, ok := resMap["methods"].(map[string]interface{}); ok {
for mName, mRaw := range methods {
if m, ok := mRaw.(map[string]interface{}); ok {
visit(curPath, mName, m)
}
}
}
if nested, ok := resMap["resources"].(map[string]interface{}); ok {
walkMethods(nested, curPath, visit)
}
}
}
// orderedKeys returns the keys of raw in their meta_data natural order if
// the current per-method key-order context has them recorded; otherwise
// alphabetical fallback.
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
if currentMethodOrder != nil && nestedPath != "" {
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
// Filter to keys that actually exist in raw (defensive)
out := make([]string, 0, len(order))
seen := make(map[string]bool)
for _, k := range order {
if _, ok := raw[k]; ok {
out = append(out, k)
seen[k] = true
}
}
// Append any keys present in raw but missing from order (defensive),
// alphabetically for determinism.
var extra []string
for k := range raw {
if !seen[k] {
extra = append(extra, k)
}
}
sort.Strings(extra)
out = append(out, extra...)
return out
}
}
// Fallback: alphabetical
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,782 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
)
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
// the suite gives the same answer on every machine. Without this, a stale
// local remote_meta.json could surface methods that aren't in the embedded
// snapshot (or alter their data) depending on the contributor's environment.
//
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
// after m.Run before exiting.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
if err != nil {
// Surface the failure rather than silently running against the host
// cache — that defeats the whole purpose of this isolation.
println("schema test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
wantType string
}{
{"string", map[string]interface{}{"type": "string"}, "string"},
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
{"number", map[string]interface{}{"type": "number"}, "number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertProperty(tt.input, "")
if got.Type != tt.wantType {
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
}
})
}
}
func TestConvertProperty_FileBinary(t *testing.T) {
input := map[string]interface{}{"type": "file", "description": "upload"}
got := convertProperty(input, "")
if got.Type != "string" {
t.Errorf("Type = %q, want \"string\"", got.Type)
}
if got.Format != "binary" {
t.Errorf("Format = %q, want \"binary\"", got.Format)
}
}
func TestConvertProperty_OptionsToEnum(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"options": []interface{}{
map[string]interface{}{"value": "banana"},
map[string]interface{}{"value": "apple"},
map[string]interface{}{"value": "banana"}, // duplicate
},
}
got := convertProperty(input, "")
// string enums preserve source order (deduped), matching the `enum`
// branch. Numeric/boolean enums would still be sorted by value.
want := []interface{}{"banana", "apple"}
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_EnumPassThrough(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"enum": []interface{}{"x", "y"},
}
got := convertProperty(input, "")
want := []interface{}{"x", "y"} // pass through, no sort
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
input := map[string]interface{}{
"type": "integer",
"options": []interface{}{
map[string]interface{}{"value": "10"},
map[string]interface{}{"value": "1"},
map[string]interface{}{"value": "2"},
},
}
got := convertProperty(input, "")
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_ListTypeFallback(t *testing.T) {
input := map[string]interface{}{
"type": "list",
"description": "ids",
}
got := convertProperty(input, "")
if got.Type != "array" {
t.Errorf("Type = %q, want %q", got.Type, "array")
}
if got.Items == nil {
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
}
}
func TestConvertProperty_MinMaxParsing(t *testing.T) {
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
got := convertProperty(input, "")
if got.Minimum == nil || *got.Minimum != 10.0 {
t.Errorf("Minimum = %v, want 10", got.Minimum)
}
if got.Maximum == nil || *got.Maximum != 50.0 {
t.Errorf("Maximum = %v, want 50", got.Maximum)
}
}
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
got := convertProperty(input, "")
if got.Minimum != nil {
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
}
}
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
// meta_data quirk: array element schema is in "properties" not "items"
input := map[string]interface{}{
"type": "array",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "array" {
t.Fatalf("Type = %q, want \"array\"", got.Type)
}
if got.Items == nil {
t.Fatal("Items is nil, want non-nil")
}
if got.Items.Type != "object" {
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
}
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
t.Errorf("Items.Properties did not contain both id and name")
}
if got.Properties != nil {
t.Error("array Property must not have top-level Properties after unfold")
}
}
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
input := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"x": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "object" {
t.Errorf("Type = %q, want \"object\"", got.Type)
}
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
t.Errorf("nested Properties not preserved")
}
}
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
input := map[string]interface{}{
"properties": map[string]interface{}{
"y": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "object" {
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
}
}
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"ref": "operator",
"annotations": []interface{}{"readOnly"},
"enumName": "FooEnum",
}
got := convertProperty(input, "")
// 这些字段直接被丢弃Property 结构里也没存这些字段,断言只有 type 设置即可
if got.Type != "string" {
t.Errorf("Type = %q", got.Type)
}
}
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"description": "hello\nworld",
"default": "",
"example": "ex",
}
got := convertProperty(input, "")
if got.Description != "hello\nworld" {
t.Errorf("Description not preserved verbatim")
}
if got.Default != "" {
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
}
if got.Example != "ex" {
t.Errorf("Example = %v, want \"ex\"", got.Example)
}
}
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if is.Type != "object" {
t.Errorf("Type = %q, want \"object\"", is.Type)
}
// top-level required: ["params"] because message_id is a required path param
if !reflect.DeepEqual(is.Required, []string{"params"}) {
t.Errorf("Required = %v, want [params]", is.Required)
}
// top-level properties only contains "params" (no body fields, no high-risk-write)
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
}
// params sub-object: required + property order
params := is.Properties.Map["params"]
if params.Type != "object" {
t.Errorf("params.Type = %q, want \"object\"", params.Type)
}
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// top-level required: ["data", "file"] — image_type body required + image file required
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
t.Errorf("Required = %v, want [data, file]", is.Required)
}
// top-level properties: data (for non-file body) + file (for binary upload)
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
}
// data sub-object carries only non-file body fields (image_type)
data := is.Properties.Map["data"]
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
t.Errorf("data.Required = %v, want [image_type]", data.Required)
}
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
}
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
t.Errorf("image_type unexpected: %+v", it)
}
if _, isFile := data.Properties.Map["image"]; isFile {
t.Errorf("image (file field) should NOT appear in data sub-object")
}
// file sub-object carries the binary upload field
file := is.Properties.Map["file"]
if file.Type != "object" {
t.Errorf("file.Type = %q, want \"object\"", file.Type)
}
if !reflect.DeepEqual(file.Required, []string{"image"}) {
t.Errorf("file.Required = %v, want [image]", file.Required)
}
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
}
img := file.Properties.Map["image"]
if img.Type != "string" {
t.Errorf("image.Type = %q, want \"string\"", img.Type)
}
if img.Format != "binary" {
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
}
}
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
// Synthesized method to avoid registry-overlay variance (remote cache may
// strip `risk` field); buildInputSchema only cares about the method map.
method := map[string]interface{}{
"risk": "high-risk-write",
"parameters": map[string]interface{}{
"message_id": map[string]interface{}{
"type": "string",
"location": "path",
"required": true,
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// yes lives at inputSchema.properties.yes (sibling of params/data)
yes, ok := is.Properties.Map["yes"]
if !ok {
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
}
if yes.Type != "boolean" {
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
}
if v, _ := yes.Default.(bool); v != false {
t.Errorf("yes.Default = %v, want false", yes.Default)
}
// yes must NOT be in top-level required
for _, r := range is.Required {
if r == "yes" {
t.Errorf("`yes` should not appear in top-level required")
}
}
// yes is appended to properties.Order
last := is.Properties.Order[len(is.Properties.Order)-1]
if last != "yes" {
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
}
}
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
t.Errorf("`yes` must not be injected for risk=read")
}
}
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)
}
// Top-level response: has_more, page_token, items
if _, ok := os.Properties.Map["items"]; !ok {
t.Fatal("items not found in outputSchema")
}
items := os.Properties.Map["items"]
if items.Type != "array" {
t.Errorf("items.Type = %q, want \"array\"", items.Type)
}
if items.Items == nil {
t.Fatal("items.Items is nil (array unfold failed)")
}
if items.Items.Type != "object" {
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
}
}
func TestConvertAccessTokens(t *testing.T) {
tests := []struct {
name string
input []interface{}
want []string
}{
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
{"user only", []interface{}{"user"}, []string{"user"}},
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
{"empty", []interface{}{}, []string{}},
{"nil", nil, []string{}},
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertAccessTokens(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestBuildMeta_FullFields(t *testing.T) {
// Synthesized method to avoid runtime variance from remote-cache overlay
// (which strips `risk` from merged services). All other field semantics
// match the real im.images.create entry in meta_data.json.
method := map[string]interface{}{
"risk": "write",
"danger": true,
"scopes": []interface{}{
"im:resource:upload",
"im:resource",
},
"accessTokens": []interface{}{"tenant"},
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
}
m := buildMeta(method)
if m.EnvelopeVersion != "1.0" {
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
}
if m.Risk != "write" {
t.Errorf("Risk = %q, want \"write\"", m.Risk)
}
if !m.Danger {
t.Errorf("Danger = false, want true")
}
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
}
if m.DocURL == "" {
t.Errorf("DocURL should be present for im.images.create")
}
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
}
if m.RequiredScopes == nil {
t.Errorf("RequiredScopes should be empty slice, not nil")
}
if len(m.RequiredScopes) != 0 {
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
}
if m.Affordance != nil {
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
}
}
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
// no risk field
}
m := buildMeta(method)
if m.Risk != "read" {
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
}
}
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
m := buildMeta(method)
if len(m.RequiredScopes) == 0 {
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
}
}
func TestParseAffordance_NilOrEmpty(t *testing.T) {
cases := []struct {
name string
raw interface{}
}{
{"nil", nil},
{"empty object", map[string]interface{}{}},
{"all-five-empty-arrays", map[string]interface{}{
"use_when": []interface{}{},
"do_not_use_when": []interface{}{},
"prerequisites": []interface{}{},
"examples": []interface{}{},
"related": []interface{}{},
}},
{"malformed (string)", "not an object"},
{"malformed (number)", 42},
{"malformed (nested type mismatch)", map[string]interface{}{
"examples": "should be a list, not a string",
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseAffordance(c.raw); got != nil {
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
}
})
}
}
func TestParseAffordance_FullPopulated(t *testing.T) {
raw := map[string]interface{}{
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
"prerequisites": []interface{}{"user 身份登录"},
"examples": []interface{}{
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
},
"related": []interface{}{"calendars.list"},
}
a := parseAffordance(raw)
if a == nil {
t.Fatal("parseAffordance returned nil, want populated")
}
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
}
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
"risk": "read",
"affordance": map[string]interface{}{
"use_when": []interface{}{"trigger"},
},
}
m := buildMeta(method)
if m.Affordance == nil {
t.Fatal("Affordance should be populated from method[\"affordance\"]")
}
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
"risk": "read",
// no docUrl
}
m := buildMeta(method)
if m.DocURL != "" {
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
}
// Verify JSON serialization omits doc_url
b, _ := json.Marshal(m)
if strings.Contains(string(b), "doc_url") {
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
}
}
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
// 装配器对空 responseBody 应生成 properties = {} (不 nil
method := map[string]interface{}{}
currentMethodOrder = nil
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)
}
if os.Properties == nil {
t.Fatal("Properties is nil, want empty OrderedProps")
}
if len(os.Properties.Order) != 0 {
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
}
}
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
if env.Name != "im reactions list" {
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
}
if env.Description == "" {
t.Errorf("Description should not be empty for im.reactions.list")
}
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
}
if env.Meta.EnvelopeVersion != "1.0" {
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
}
}
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
// im.chat.members.create — resource path is one element "chat.members" with
// an internal dot. Substituted from plan's `bots` because remote-cache
// overlay strips `bots` from the loaded method map on this environment;
// the assertion is about name joining, not method specifics.
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
// chat.members resourcePath stays as one element in the slice with a dot;
// name should split it to "im chat.members create" — we keep the dot as-is
// inside the resource segment to round-trip with completion logic.
if env.Name != "im chat.members create" {
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
}
}
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
// Assemble twice; JSON output must be byte-identical (determinism).
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
ja, _ := json.MarshalIndent(a, "", " ")
jb, _ := json.MarshalIndent(b, "", " ")
if string(ja) != string(jb) {
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
}
}
func TestAssembleService_Im(t *testing.T) {
spec := registry.LoadFromMeta("im")
envs := AssembleService("im", spec, nil)
if len(envs) == 0 {
t.Fatal("expected non-empty envelopes for service im")
}
// Every envelope.Name starts with "im "
for _, e := range envs {
if !strings.HasPrefix(e.Name, "im ") {
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
}
}
// Sorted by name
for i := 1; i < len(envs); i++ {
if envs[i-1].Name > envs[i].Name {
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
}
}
}
func TestAssembleService_FilterByAccessToken(t *testing.T) {
spec := registry.LoadFromMeta("im")
// Filter to bot-only (--as bot, which corresponds to "tenant")
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
for _, t := range tokens {
if s, _ := t.(string); s == "tenant" {
return true
}
}
return false
})
// Every envelope's _meta.access_tokens must contain "bot"
for _, e := range envs {
found := false
for _, t := range e.Meta.AccessTokens {
if t == "bot" {
found = true
break
}
}
if !found {
t.Errorf("envelope %q does not declare bot access", e.Name)
}
}
}
func TestAssembleAll_AtLeast193(t *testing.T) {
envs := AssembleAll(nil)
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
// embedded meta_data.json directly, so the count is stable across machines.
if len(envs) < 193 {
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
}
// Spot check: im reactions list should be present
found := false
for _, e := range envs {
if e.Name == "im reactions list" {
found = true
break
}
}
if !found {
t.Errorf("im reactions list not found in AssembleAll output")
}
}
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
// real embedded meta_data.json via the registry package.
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
t.Helper()
spec := registry.LoadFromMeta(service)
if spec == nil {
t.Fatalf("service %q not found in registry", service)
}
resources, _ := spec["resources"].(map[string]interface{})
resKey := strings.Join(resourcePath, ".")
res, ok := resources[resKey].(map[string]interface{})
if !ok {
t.Fatalf("resource %q.%s not found", service, resKey)
}
methods, _ := res["methods"].(map[string]interface{})
m, ok := methods[methodName].(map[string]interface{})
if !ok {
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
}
return m
}

233
internal/schema/lint.go Normal file
View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"errors"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
)
var validJSONSchemaTypes = map[string]bool{
"string": true,
"integer": true,
"number": true,
"boolean": true,
"array": true,
"object": true,
}
var validAccessTokens = map[string]bool{
"user": true,
"bot": true,
}
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
// means the envelope is compliant.
func lintEnvelope(env Envelope) []error {
var errs []error
// ---- L1: structural ----
if env.Name == "" {
errs = append(errs, errors.New("L1: name must not be empty"))
}
if env.InputSchema == nil {
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
} else {
if env.InputSchema.Type != "object" {
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
}
if env.InputSchema.Properties == nil {
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
}
}
if env.OutputSchema == nil {
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
} else {
if env.OutputSchema.Type != "object" {
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
}
}
if env.Meta == nil {
errs = append(errs, errors.New("L1: _meta must not be nil"))
// Cannot continue meta-dependent checks
return errs
}
if env.Meta.EnvelopeVersion != "1.0" {
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
}
// L1: validate every Property type recursively
if env.InputSchema != nil && env.InputSchema.Properties != nil {
validatePropertyTypes(env.InputSchema.Properties, &errs)
}
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
validatePropertyTypes(env.OutputSchema.Properties, &errs)
}
// ---- L2: type-level consistency ----
if env.InputSchema != nil && env.InputSchema.Properties != nil {
// Walk the whole property tree so format/min-max checks reach leaf
// fields nested under the params/data wrapper.
walkForL2(env.InputSchema.Properties, &errs)
// Top-level required keys must exist in top-level properties.
for _, r := range env.InputSchema.Required {
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
}
}
}
// ---- L3: cross-field self-consistency ----
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
if env.Meta.Danger != dangerExpected {
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
}
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
// injected only for risk == RiskHighRiskWrite.
hasYes := false
if env.InputSchema != nil && env.InputSchema.Properties != nil {
_, hasYes = env.InputSchema.Properties.Map["yes"]
}
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
if hasYes != wantYes {
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
}
if len(env.Meta.AccessTokens) == 0 {
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
}
for _, t := range env.Meta.AccessTokens {
if !validAccessTokens[t] {
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
}
}
return errs
}
// walkForL2 recursively applies per-field L2 checks (format:binary on
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
// Required only matters on object-typed Properties (e.g. the params / data
// wrappers); leaf scalars ignore it.
func walkForL2(props *OrderedProps, errs *[]error) {
if props == nil {
return
}
for _, k := range props.Order {
p := props.Map[k]
if p.Format == "binary" && p.Type != "string" {
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
}
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
}
if len(p.Required) > 0 && p.Properties != nil {
for _, r := range p.Required {
if _, ok := p.Properties.Map[r]; !ok {
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
}
}
}
if p.Properties != nil {
walkForL2(p.Properties, errs)
}
}
}
// validatePropertyTypes walks an OrderedProps tree and asserts:
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
// - array Properties have Items
//
// Errors are appended to *errs.
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
if props == nil {
return
}
for _, k := range props.Order {
p := props.Map[k]
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
}
if p.Type == "array" && p.Items == nil {
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
}
if p.Properties != nil {
validatePropertyTypes(p.Properties, errs)
}
// Validate the array-element schema itself, not only its child
// properties — a primitive element with an invalid type (e.g.
// `items.type = "list"`) would otherwise slip past lint.
if p.Items != nil {
validateItemSchema(k, p.Items, errs)
}
}
}
// validateItemSchema checks a single array element schema for invalid types,
// then recurses into any further nested properties/items.
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
}
if item.Type == "array" && item.Items == nil {
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
}
if item.Properties != nil {
validatePropertyTypes(item.Properties, errs)
}
if item.Items != nil {
validateItemSchema(parentKey, item.Items, errs)
}
}
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
// If the measured rate drops below the baseline, t.Logf emits a warning but
// does NOT fail the test. Adjust these constants upward as meta_data quality
// improves over time.
var coverageBaseline = map[string]float64{
"description": 0.99,
"scopes": 1.00,
"doc_url": 0.98,
"risk": 0.96,
}
// measureCoverage returns the non-empty rate for each tracked metric.
func measureCoverage(envs []Envelope) map[string]float64 {
if len(envs) == 0 {
return map[string]float64{
"description": 0,
"scopes": 0,
"doc_url": 0,
"risk": 0,
}
}
total := float64(len(envs))
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
for _, e := range envs {
if e.Description != "" {
descNonEmpty++
}
if e.Meta == nil {
continue
}
if len(e.Meta.Scopes) > 0 {
scopesNonEmpty++
}
if e.Meta.DocURL != "" {
docURLNonEmpty++
}
if e.Meta.Risk != "" {
riskNonEmpty++
}
}
return map[string]float64{
"description": descNonEmpty / total,
"scopes": scopesNonEmpty / total,
"doc_url": docURLNonEmpty / total,
"risk": riskNonEmpty / total,
}
}

View File

@@ -0,0 +1,379 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
)
// validEnvelope builds a baseline valid envelope used as a starting point in
// negative tests below.
func validEnvelope() Envelope {
props := &OrderedProps{Map: map[string]Property{}}
return Envelope{
Name: "x y z",
Description: "ok",
InputSchema: &InputSchema{
Type: "object",
Properties: props,
},
OutputSchema: &OutputSchema{
Type: "object",
Properties: &OrderedProps{Map: map[string]Property{}},
},
Meta: &Meta{
EnvelopeVersion: "1.0",
AccessTokens: []string{"user"},
Risk: "read",
Danger: false,
},
}
}
func TestLintEnvelope_Valid(t *testing.T) {
env := validEnvelope()
errs := lintEnvelope(env)
if len(errs) != 0 {
t.Errorf("expected no errors, got: %v", errs)
}
}
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "empty name",
mutate: func(e *Envelope) { e.Name = "" },
wantSub: "name",
},
{
name: "nil InputSchema",
mutate: func(e *Envelope) { e.InputSchema = nil },
wantSub: "inputSchema",
},
{
name: "inputSchema type not object",
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
wantSub: "inputSchema.type",
},
{
name: "nil OutputSchema",
mutate: func(e *Envelope) { e.OutputSchema = nil },
wantSub: "outputSchema",
},
{
name: "nil Meta",
mutate: func(e *Envelope) { e.Meta = nil },
wantSub: "_meta",
},
{
name: "wrong envelope version",
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
wantSub: "envelope_version",
},
{
name: "invalid property type",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"x"}
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
},
wantSub: "invalid type",
},
{
name: "array missing items",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"x"}
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
},
wantSub: "items",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "format binary on non-string",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"f"}
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
},
wantSub: "format: binary",
},
{
name: "required key not in properties",
mutate: func(e *Envelope) {
e.InputSchema.Required = []string{"nonexistent"}
},
wantSub: "required",
},
{
name: "minimum >= maximum",
mutate: func(e *Envelope) {
min, max := 50.0, 10.0
e.InputSchema.Properties.Order = []string{"n"}
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
},
wantSub: "minimum",
},
{
// Regression guard: walkForL2 must recurse into the params/data
// sub-objects introduced by the 4-bucket inputSchema, not only the
// top-level Properties map.
name: "format binary on non-string inside params sub-object",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"params"}
e.InputSchema.Properties.Map["params"] = Property{
Type: "object",
Properties: &OrderedProps{
Order: []string{"id"},
Map: map[string]Property{
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
},
},
}
},
wantSub: "format: binary",
},
{
name: "sub-object required references missing property",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"data"}
e.InputSchema.Properties.Map["data"] = Property{
Type: "object",
Required: []string{"ghost"}, // not in properties below
Properties: &OrderedProps{
Order: []string{"real"},
Map: map[string]Property{"real": {Type: "string"}},
},
}
},
wantSub: "ghost",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "danger true but risk read",
mutate: func(e *Envelope) {
e.Meta.Danger = true
e.Meta.Risk = "read"
},
wantSub: "danger",
},
{
name: "high-risk-write without yes",
mutate: func(e *Envelope) {
e.Meta.Risk = "high-risk-write"
e.Meta.Danger = true
// no yes injection
},
wantSub: "yes",
},
{
name: "yes injected but risk not high-risk-write",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"yes"}
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
},
wantSub: "yes",
},
{
name: "empty access_tokens",
mutate: func(e *Envelope) {
e.Meta.AccessTokens = []string{}
},
wantSub: "access_tokens",
},
{
name: "invalid access_token value",
mutate: func(e *Envelope) {
e.Meta.AccessTokens = []string{"admin"}
},
wantSub: "access_tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestMeasureCoverage_Counts(t *testing.T) {
envs := []Envelope{
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
}
c := measureCoverage(envs)
// 2/3 have non-empty description = ~0.667
if c["description"] < 0.66 || c["description"] > 0.67 {
t.Errorf("description coverage = %v, want ~0.667", c["description"])
}
// 2/3 have non-empty scopes
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
}
// 2/3 have doc_url
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
}
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
if c["risk"] < 0.66 || c["risk"] > 0.67 {
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
}
}
// isKnownDataInconsistency returns true for lint errors that originate from
// real meta_data quality issues we still have to ship around in PR-1. With
// Task 17b the assembler walks embedded data only, so overlay-induced
// inconsistencies (risk-stripping) no longer appear; only the true embedded
// meta_data data-quality patterns remain.
//
// As meta_data quality improves this filter should be tightened/removed so
// TestAllEnvelopesPass becomes a hard gate again.
func isKnownDataInconsistency(msg string) bool {
switch {
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
// `risk="write"` but `danger` is missing (defaults to false). Needs a
// meta_data fix to set danger=true on these write methods.
return true
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
// meta_data fix to set the proper risk level on these methods.
return true
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
// meta_data sets min == max on some fields (e.g.
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
// as min >= max. Real fix is in meta_data.
return true
}
return false
}
func TestAllEnvelopesPass(t *testing.T) {
failCount := 0
knownWarnings := 0
knownEnvelopes := map[string]bool{}
// Use embedded data only so the gate is deterministic across machines
// (matches Task 17b: envelope assembly is overlay-independent).
for _, svc := range registry.EmbeddedServiceNames() {
spec := registry.EmbeddedSpec(svc)
envs := AssembleService(svc, spec, nil)
for _, env := range envs {
errs := lintEnvelope(env)
if len(errs) == 0 {
continue
}
var realErrs []error
for _, e := range errs {
if isKnownDataInconsistency(e.Error()) {
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
knownWarnings++
knownEnvelopes[env.Name] = true
continue
}
realErrs = append(realErrs, e)
}
if len(realErrs) > 0 {
for _, e := range realErrs {
t.Errorf("%s: %v", env.Name, e)
}
failCount++
}
}
}
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
if failCount > 0 {
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
}
// L4 coverage report (warn-only via t.Logf)
all := AssembleAll(nil)
c := measureCoverage(all)
for metric, rate := range c {
baseline := coverageBaseline[metric]
if rate < baseline {
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
} else {
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
}
}
}

30
internal/schema/path.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import "strings"
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
// slice of path segments. It accepts two equivalent forms:
//
// lark-cli schema im.messages.reply -> single arg, split on "."
// lark-cli schema im messages reply -> multiple args, used as-is
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
// arguments individually if your shell needs it. Nested resources keep their
// internal dots (e.g. "chat.members").
//
// Returns nil for zero args (bare invocation).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"reflect"
"testing"
)
func TestParsePath(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{"empty args -> nil", nil, nil},
{"empty slice -> nil", []string{}, nil},
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
{"single no-dot", []string{"im"}, []string{"im"}},
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParsePath(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}

164
internal/schema/types.go Normal file
View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"bytes"
"encoding/json"
"fmt"
"sort"
)
// Envelope is the MCP Tool spec contract for a single API method command.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema *InputSchema `json:"inputSchema"`
OutputSchema *OutputSchema `json:"outputSchema"`
Meta *Meta `json:"_meta"`
}
// InputSchema is JSON Schema Draft 2020-12 flattened.
//
// Required is intentionally rendered (no omitempty) so the envelope shape
// stays stable for AI consumers — an empty []string means "no required
// fields" rather than "schema is missing the field".
type InputSchema struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties *OrderedProps `json:"properties"`
}
// OutputSchema wraps responseBody into a JSON Schema object.
type OutputSchema struct {
Type string `json:"type"`
Properties *OrderedProps `json:"properties"`
}
// Property is one field's JSON Schema shape, recursive.
//
// Required is used when Property describes a nested object (e.g. the
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Default interface{} `json:"default,omitempty"`
Example interface{} `json:"example,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Format string `json:"format,omitempty"`
Required []string `json:"required,omitempty"`
Properties *OrderedProps `json:"properties,omitempty"`
Items *Property `json:"items,omitempty"`
}
// Meta is the Lark-specific extension namespace.
type Meta struct {
EnvelopeVersion string `json:"envelope_version"`
Scopes []string `json:"scopes"`
RequiredScopes []string `json:"required_scopes"`
AccessTokens []string `json:"access_tokens"`
Danger bool `json:"danger"`
Risk string `json:"risk"`
DocURL string `json:"doc_url,omitempty"`
Affordance *Affordance `json:"affordance,omitempty"`
}
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one example entry: a one-line description plus a
// ready-to-run lark-cli command string.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
// It is used wherever JSON output must reflect meta_data.json's natural field
// order rather than Go's default alphabetical map encoding.
type OrderedProps struct {
Order []string
Map map[string]Property
}
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
// Map has entries, fall back to alphabetical key order over Map so callers
// that only populated Map (no explicit ordering) still see their fields.
func (o *OrderedProps) MarshalJSON() ([]byte, error) {
if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) {
return []byte("{}"), nil
}
keys := o.Order
if len(keys) == 0 {
keys = make([]string, 0, len(o.Map))
for k := range o.Map {
keys = append(keys, k)
}
sort.Strings(keys)
}
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
keyJSON, err := json.Marshal(k)
if err != nil {
return nil, fmt.Errorf("marshal key %q: %w", k, err)
}
buf.Write(keyJSON)
buf.WriteByte(':')
valJSON, err := json.Marshal(o.Map[k])
if err != nil {
return nil, fmt.Errorf("marshal value for %q: %w", k, err)
}
buf.Write(valJSON)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// UnmarshalJSON parses an object preserving key order via json.Decoder.Token().
// Used for round-tripping in tests (and future golden update flows).
func (o *OrderedProps) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
tok, err := dec.Token()
if err != nil {
return err
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return fmt.Errorf("expected object, got %v", tok)
}
o.Order = nil
o.Map = make(map[string]Property)
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return err
}
key, ok := keyTok.(string)
if !ok {
return fmt.Errorf("expected string key, got %v", keyTok)
}
var prop Property
if err := dec.Decode(&prop); err != nil {
return err
}
o.Order = append(o.Order, key)
o.Map[key] = prop
}
if _, err := dec.Token(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"encoding/json"
"testing"
)
// OrderedProps 在测试里验证MarshalJSON 按 Order 切片顺序输出 key跳过 Go map 默认字母序。
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
op := &OrderedProps{
Order: []string{"z_first", "a_second", "m_third"},
Map: map[string]Property{
"z_first": {Type: "string"},
"a_second": {Type: "integer"},
"m_third": {Type: "boolean"},
},
}
b, err := json.Marshal(op)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
got := string(b)
want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}`
if got != want {
t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want)
}
}
func TestOrderedProps_MarshalJSON_Empty(t *testing.T) {
op := &OrderedProps{Order: nil, Map: nil}
b, err := json.Marshal(op)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != "{}" {
t.Errorf("empty OrderedProps should marshal to {}, got: %s", b)
}
}
func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) {
in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`)
var op OrderedProps
if err := json.Unmarshal(in, &op); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if len(op.Order) != 2 {
t.Fatalf("expected 2 keys, got %d", len(op.Order))
}
if op.Order[0] != "first" || op.Order[1] != "second" {
t.Errorf("unmarshal lost order: got %v", op.Order)
}
if op.Map["first"].Type != "string" {
t.Errorf("first.type mismatch")
}
}

View File

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

View File

@@ -110,6 +110,52 @@ function getMirrorUrls(env) {
return urls;
}
/**
* Decide from a `curl --version` output whether curl is >= 7.70.0 — the
* release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure
* (no I/O) so the version-comparison logic can be unit tested without
* spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the
* trailing "libcurl/X.Y.Z" that may report a different version.
*
* @param {string} versionOutput raw stdout of `curl --version`
* @returns {boolean} true when the parsed version is >= 7.70.0
*/
function isCurlVersionSupported(versionOutput) {
const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 7 || (major === 7 && minor >= 70);
}
// Memoized probe result. curl's version is invariant for the lifetime of the
// install, while download() runs once per mirror URL — so probe at most once.
let _curlSupportsSslRevokeBestEffort;
/**
* Detect whether the system curl supports --ssl-revoke-best-effort. Older
* versions (notably the curl 7.55.1 shipped with older Windows 10 builds)
* exit with "unknown option" if the flag is passed.
*
* @returns {boolean} true when curl >= 7.70.0 is available
*/
function curlSupportsSslRevokeBestEffort() {
if (_curlSupportsSslRevokeBestEffort !== undefined) {
return _curlSupportsSslRevokeBestEffort;
}
try {
const output = execFileSync("curl", ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 5000,
});
_curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output);
} catch (_) {
_curlSupportsSslRevokeBestEffort = false;
}
return _curlSupportsSslRevokeBestEffort;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -119,8 +165,11 @@ function download(url, destPath) {
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
// errors when the certificate revocation list server is unreachable.
// Only use it when the system curl is new enough (>= 7.70.0).
if (isWindows && curlSupportsSslRevokeBestEffort()) {
args.unshift("--ssl-revoke-best-effort");
}
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
@@ -294,4 +343,4 @@ if (require.main === module) {
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -278,3 +278,55 @@ describe("resolveMirrorUrls", () => {
);
});
});
describe("isCurlVersionSupported", () => {
// --ssl-revoke-best-effort was introduced in curl 7.70.0; below that the
// flag is unknown and `curl` exits non-zero (see issue #1099).
it("returns false for curl 7.55.1 (older Windows 10, flag unknown)", () => {
assert.equal(
isCurlVersionSupported("curl 7.55.1 (x86_64-pc-win32) libcurl/7.55.1"),
false
);
});
it("returns false for curl 7.69.0 (just below the 7.70.0 threshold)", () => {
assert.equal(
isCurlVersionSupported("curl 7.69.0 (x86_64-pc-win32) libcurl/7.69.0"),
false
);
});
it("returns true for curl 7.70.0 (flag introduced here)", () => {
assert.equal(
isCurlVersionSupported("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0"),
true
);
});
it("returns true for a future major (curl 8.x)", () => {
assert.equal(
isCurlVersionSupported("curl 8.5.0 (x86_64-apple-darwin) libcurl/8.5.0"),
true
);
});
it("returns false when no version can be parsed", () => {
assert.equal(isCurlVersionSupported("not a curl version string"), false);
assert.equal(isCurlVersionSupported(""), false);
});
it("reads the leading 'curl X.Y.Z', not the trailing libcurl/X.Y.Z", () => {
// Guards the regex against latching onto "libcurl/7.55.1" when the
// curl binary itself is new enough.
assert.equal(
isCurlVersionSupported("curl 8.0.0 (x86_64) libcurl/7.55.1"),
true
);
});
it("does not match a 'libcurl X.Y.Z' token (anchored to leading curl)", () => {
// "libcurl 8.0.0" contains the substring "curl 8.0.0"; the leading
// anchor keeps it from being mistaken for a real curl version line.
assert.equal(isCurlVersionSupported("libcurl 8.0.0"), false);
});
});

View File

@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app.access_scope:read"},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app.access_scope:write"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:publish"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -268,6 +268,39 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command.
func TestBaseDashboardBlockExecuteGetData(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "文本", "alias": "dim_text"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"},
},
"main_data": []interface{}{
map[string]interface{}{
"dim_text": map[string]interface{}{"value": "A"},
"me_count": map[string]interface{}{"value": 3},
},
},
},
},
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
t.Run("with data-config", func(t *testing.T) {
@@ -537,6 +570,19 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
}
}
// TestBaseDashboardBlockDryRun_GetData tests the +dashboard-block-get-data --dry-run flag.
func TestBaseDashboardBlockDryRun_GetData(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_a", "--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockGetData, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/blk_a/data") || !strings.Contains(got, "blk_a") {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag.
func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

View File

@@ -11,6 +11,7 @@ import (
"image"
"image/color"
"image/png"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -2200,7 +2201,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -2228,8 +2229,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
Status: 500,
Status: 403,
RawBody: []byte("server error"),
Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}},
})
tmpDir := t.TempDir()
@@ -2258,6 +2260,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)
}

View File

@@ -146,7 +146,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {
t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want))

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseDashboardBlockGetData = common.Shortcut{
Service: "base",
Command: "+dashboard-block-get-data",
Description: "Get computed data for a dashboard chart block",
Risk: "read",
Scopes: []string{"base:dashboard:read"},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBlockGetData(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardBlockGetData(runtime)
},
}

View File

@@ -104,6 +104,14 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
Params(params)
}
// dryRunDashboardBlockGetData returns a DryRunAPI for getting computed data for a dashboard block.
func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/blocks/:block_id/data").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
@@ -261,6 +269,16 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockGetData retrieves computed data for a dashboard chart block.
func executeDashboardBlockGetData(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// executeDashboardBlockCreate creates a new dashboard block.
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)

View File

@@ -787,7 +787,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
QueryParams: query,
})
if err != nil {
return nil, output.ErrNetwork("download failed: %v", err)
return nil, err
}
defer resp.Body.Close()
@@ -835,6 +835,13 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detail := map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
@@ -842,10 +849,7 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
Code: exitErr.Detail.Code,
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
Detail: detail,
},
Err: err,
}
@@ -865,6 +869,19 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
}
}
func baseAttachmentDownloadLogID(err error) string {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return ""
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
return ""
}
logID, _ := detail["log_id"].(string)
return strings.TrimSpace(logID)
}
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
return true

View File

@@ -84,6 +84,7 @@ func Shortcuts() []common.Shortcut {
BaseDashboardArrange,
BaseDashboardBlockList,
BaseDashboardBlockGet,
BaseDashboardBlockGetData,
BaseDashboardBlockCreate,
BaseDashboardBlockUpdate,
BaseDashboardBlockDelete,

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -170,13 +171,34 @@ func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (ma
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"]))
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} {
logID := ""
if apiResp != nil {
logID = strings.TrimSpace(apiResp.LogId())
}
if logID == "" {
return detail
}
detailMap, ok := detail.(map[string]interface{})
if !ok {
if detail == nil {
return map[string]interface{}{"log_id": logID}
}
return map[string]interface{}{"error": detail, "log_id": logID}
}
if _, exists := detailMap["log_id"]; !exists {
detailMap["log_id"] = logID
}
return detailMap
}
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {

View File

@@ -7,10 +7,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"strings"
"sync/atomic"
@@ -21,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
var commonDriveMediaUploadTestSeq atomic.Int64
@@ -459,6 +462,24 @@ func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
t.Fatalf("expected API error, got %v", err)
}
})
t.Run("api code error includes log_id", func(t *testing.T) {
t.Parallel()
resp := &larkcore.ApiResp{
RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`),
Header: http.Header{"X-Tt-Logid": []string{"202605270002"}},
}
_, err := ParseDriveMediaUploadResponse(resp, "upload media failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["log_id"] != "202605270002" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
})
}
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -72,6 +73,13 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// Lang returns the user's preference as a canonical locale, or "" if unset or
// unrecognized; callers choose their own fallback.
func (ctx *RuntimeContext) Lang() i18n.Lang {
lang, _ := i18n.Parse(string(ctx.Config.Lang))
return lang
}
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestRuntimeContext_Lang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want i18n.Lang
}{
{"canonical locale", i18n.LangJaJP, i18n.LangJaJP},
{"legacy short value normalizes", "ja", i18n.LangJaJP},
{"legacy short zh normalizes", "zh", i18n.LangZhCN},
{"unset stays empty", "", ""},
{"unrecognized stays empty", "klingon", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := ctx.Lang(); got != tt.want {
t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -16,7 +16,7 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
@@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
// validateFetchDetail 非 xml 格式markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
// validateFetchDetail 非 xml 格式markdown不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))

View File

@@ -353,7 +353,6 @@ func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -395,7 +394,6 @@ func TestDriveInspectExecute_DocxURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -458,7 +456,6 @@ func TestDriveInspectExecute_WikiURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -487,7 +484,6 @@ func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
}
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -524,7 +520,6 @@ func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
}
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -548,7 +543,6 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
secureLabelUpdateScope = "docs:secure_label:write_only"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
var DriveSecureLabelList = common.Shortcut{
Service: "drive",
Command: "+secure-label-list",
Description: "List secure labels available to the current user",
Risk: "read",
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
{Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 10 {
return output.ErrValidation("--page-size must be between 1 and 10")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List secure labels available to the current user").
GET("/open-apis/drive/v2/my_secure_labels").
Params(buildSecureLabelListParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPI("GET",
"/open-apis/drive/v2/my_secure_labels",
buildSecureLabelListParams(runtime),
nil,
)
if err != nil {
return err
}
runtime.OutFormat(data, nil, nil)
return nil
},
}
// DriveSecureLabelUpdate updates the secure label on a Drive file/document.
var DriveSecureLabelUpdate = common.Shortcut{
Service: "drive",
Command: "+secure-label-update",
Description: "Update the secure label on a Drive file or document",
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
data, err := runtime.CallAPI("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
if lang := runtime.Str("lang"); lang != "" {
params["lang"] = lang
}
return params
}
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveSecureLabelList_DryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--page-size", "5",
"--page-token", "page_1",
"--lang", "zh",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/my_secure_labels",
`"GET"`,
`"page_size": 5`,
`"page_token": "page_1"`,
`"lang": "zh"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--page-size", "11",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "page-size") {
t.Fatalf("expected page-size validation error, got: %v", err)
}
}
func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "7217780879644737540", "name": "L1"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"L1"`) {
t.Fatalf("stdout missing label:\n%s", stdout.String())
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", "7217780879644737539",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/files/doxTok123/secure_label",
`"PATCH"`,
`"docx"`,
`"id": "7217780879644737539"`,
`"file_token": "doxTok123"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["id"] != "7217780879644737539" {
t.Fatalf("id = %v, want label id", body["id"])
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 403,
Body: map[string]interface{}{
"code": 1063013, "msg": "Security label downgrade requires approval",
},
})
targetURL := "https://example.feishu.cn/docx/doxTok123"
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", targetURL,
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 1063013 error")
}
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
}
}

View File

@@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,
DriveInspect,
}

View File

@@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+sync",
"+task_result",
"+apply-permission",
"+secure-label-list",
"+secure-label-update",
"+search",
"+inspect",
}

View File

@@ -729,6 +729,18 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, "Bot/user membership in the target chat is not verified") ||
!strings.Contains(got, "Bot/User can NOT be out of the chat") {
t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got)
}
})
t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -742,6 +754,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`
membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."`
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) {
t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got)
}
})
t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,om_2",

View File

@@ -32,6 +32,14 @@ type ConvertContext struct {
// SenderNames is a shared cache of open_id -> display name, accumulated across messages
// to avoid redundant contact API calls. May be nil.
SenderNames map[string]string
// MergeForwardSubItems is an optional pre-fetched cache of merge_forward
// sub-message lists, keyed by merge_forward message_id. When set, the
// merge_forward converter uses the cached entry instead of issuing its
// own GET; populated by callers via PrefetchMergeForwardSubItems before
// the FormatMessageItem loop. nil means "no prefetch — fall back to the
// per-message inline GET", which keeps non-shortcut callers (events,
// ad-hoc tests) working unchanged.
MergeForwardSubItems map[string][]map[string]interface{}
}
// converters maps message types to their ContentConverter implementations.
@@ -119,6 +127,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if len(senderNames) > 0 {
nameCache = senderNames[0]
}
return formatMessageItem(m, runtime, nameCache, nil)
}
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
// pre-fetched merge_forward sub-message map (typically built via
// PrefetchMergeForwardSubItems) through to the merge_forward converter so it
// can skip its own per-message GET. Shortcuts that iterate a page of raw
// items should pre-fetch once and call this variant in the loop to avoid the
// N × ~1s serial-merge_forward stall in the original code path.
func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
msgType, _ := m["msg_type"].(string)
messageId, _ := m["message_id"].(string)
mentions, _ := m["mentions"].([]interface{})
@@ -129,11 +151,12 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if body, ok := m["body"].(map[string]interface{}); ok {
rawContent, _ := body["content"].(string)
content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
MergeForwardSubItems: mergePrefetch,
})
}
@@ -155,6 +178,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}

View File

@@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) {
}
}
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_edit",
"updated": true,
"create_time": "1710500000",
"update_time": "1710600000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"edited"}`},
}
got := FormatMessageItem(raw, nil)
want := common.FormatTime("1710600000")
if got["update_time"] != want {
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
}
}
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_no_edit",
"updated": false,
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if _, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
}
}
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
// for unedited messages, server returns update_time == create_time. We must
// NOT echo it through, otherwise every message looks "edited" to consumers.
// Gate the output on updated==true.
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_unchanged",
"updated": false,
"create_time": "1710500000",
"update_time": "1710500000", // server echoes create_time
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if v, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
}
}
func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)

View File

@@ -4,11 +4,11 @@
package convertlib
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/validate"
@@ -16,28 +16,53 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// mergeForwardPrefetchConcurrency caps in-flight merge_forward sub-message
// fetches when a shortcut pre-scans a page for merge_forward messages and
// prefetches their children concurrently. Each call is one ~700ms-1s
// GET /open-apis/im/v1/messages/{id} per merge_forward — strictly serial in
// FormatMessageItem before this change, which turned page-size 50 + 5
// merge_forward messages into ~8.5s of stall (measured on a real chat).
// GET /open-apis/im/v1/messages/{id} has no published per-app rate-limit at
// these levels, so we set this higher than the reactions batch_query cap
// (which sits at 4 to stay well under the gateway-layer 50/s + 1000/min
// explicit ceiling on the reactions endpoint).
const mergeForwardPrefetchConcurrency = 8
type mergeForwardConverter struct{}
// Convert expands merge_forward sub-messages into a tree when runtime is available,
// otherwise falls back to a summary string.
// Convert expands merge_forward sub-messages into a tree when runtime is
// available (or a pre-fetched cache was supplied), otherwise falls back to a
// summary string.
//
// When ctx.MergeForwardSubItems is non-nil (set by callers that pre-fetched
// the page's merge_forward children concurrently via
// PrefetchMergeForwardSubItems), Convert uses the cached items and skips the
// HTTP fetch entirely — this is how the shortcut layer turns N serial
// per-merge_forward GETs into one bounded-concurrency fan-out before the
// FormatMessageItem loop runs.
func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
// When runtime is available, fetch sub-messages via API and expand into a tree.
// merge_forward body.content is typically a plain-text placeholder (e.g. "Merged and Forwarded Message"),
// not JSON with create_message_ids, so we must rely on the API to get actual sub-messages.
// Fast path: caller pre-fetched this merge_forward's sub-tree.
if ctx.MergeForwardSubItems != nil && ctx.MessageID != "" {
if cached, ok := ctx.MergeForwardSubItems[ctx.MessageID]; ok {
return renderMergeForwardTree(ctx, cached)
}
}
// Slow path: no pre-fetch; fall back to a per-merge_forward GET. Kept so
// callers that don't pre-fetch (e.g. event subscribers, ad-hoc Convert
// invocations in tests) still produce correct output, just serially.
// merge_forward body.content is typically a plain-text placeholder, not
// JSON with create_message_ids, so we must rely on the API to get actual
// sub-messages.
if ctx.Runtime != nil && ctx.MessageID != "" {
subItems, err := fetchMergeForwardSubMessages(ctx.MessageID, ctx.Runtime)
if err != nil {
return fmt.Sprintf("[Merged forward: fetch failed: %s]", err)
}
if len(subItems) > 0 {
// Resolve sender names using shared cache to avoid redundant API calls across merge_forward messages
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
return renderMergeForwardTree(ctx, subItems)
}
}
// Fallback: try to extract message IDs from content (some older formats include them)
// Final fallback: try to extract message IDs from content (some older formats include them)
ids := ParseMergeForwardIDs(ctx.RawContent)
if len(ids) > 0 {
return fmt.Sprintf("[Merged forward: %d messages]", len(ids))
@@ -45,31 +70,158 @@ func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
return "[Merged forward]"
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward container
// via a single API call. Returns a flat list of raw message items with upper_message_id
// for tree reconstruction.
// renderMergeForwardTree resolves sender names for the supplied sub-items and
// produces the formatted forwarded-messages tree. Shared by the prefetch fast
// path and the inline fetch fallback so both produce identical output.
func renderMergeForwardTree(ctx *ConvertContext, subItems []map[string]interface{}) string {
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
}
// PrefetchMergeForwardSubItems scans rawItems for merge_forward messages,
// concurrently fetches each one's flat sub-message list, and returns a map
// keyed by the merge_forward message_id. Callers thread the returned map
// through FormatMessageItemWithMergePrefetch (or directly into a
// ConvertContext.MergeForwardSubItems) so the per-item conversion loop can
// reuse cached sub-trees instead of issuing its own serial GET.
//
// Each fetch is independent (different message_id, different sub-tree), so
// concurrent goroutines never contend on shared mutable state — the result
// map is written under a mutex purely to make the map safe for concurrent
// inserts.
//
// On fetch failure: emit a stderr warning and intentionally do NOT insert
// the failed id into the result map. The downstream
// mergeForwardConverter.Convert path keys off "is this id present in the
// prefetch?" — by leaving the key absent on failure, Convert falls through
// to its inline-fetch slow path, which (a) gets a second attempt at the
// GET, and (b) if that ALSO fails, surfaces the real "[Merged forward:
// fetch failed: ...]" string the user used to see in stdout. Inserting nil
// would have silently produced an empty <forwarded_messages> tree instead,
// dropping the failure signal from the user-visible output.
//
// When nameCache is non-nil, this function also runs one batched
// ResolveSenderNames across every sub-item it fetched, populating the cache
// before returning. Without this step, each per-merge_forward render in the
// caller's loop would issue its own contact API request for any uncached
// sender, re-introducing an N × ~400ms serial stall (measured at 5
// merge_forwards × ~400ms = ~2s in production traces). Pre-populating the
// cache makes those per-render ResolveSenderNames calls effective no-ops.
func PrefetchMergeForwardSubItems(runtime *common.RuntimeContext, rawItems []interface{}, nameCache map[string]string) map[string][]map[string]interface{} {
if runtime == nil || len(rawItems) == 0 {
return nil
}
var ids []string
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
if m == nil {
continue
}
if mt, _ := m["msg_type"].(string); mt != "merge_forward" {
continue
}
id, _ := m["message_id"].(string)
if id == "" {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
result := make(map[string][]map[string]interface{}, len(ids))
if len(ids) == 1 {
// Single-message fast path: no goroutine overhead. Matches the
// pre-existing serial behavior bit-for-bit when only one
// merge_forward is present.
items, err := fetchMergeForwardSubMessages(ids[0], runtime)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", ids[0], err)
// Leave the key absent so Convert falls back to its inline GET
// path and surfaces "[Merged forward: fetch failed: ...]" if
// the retry also fails. See function godoc.
} else {
result[ids[0]] = items
}
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
var mu sync.Mutex
sem := make(chan struct{}, mergeForwardPrefetchConcurrency)
var wg sync.WaitGroup
for _, id := range ids {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, err := fetchMergeForwardSubMessages(id, runtime)
mu.Lock()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", id, err)
// Leave the key absent — see fast-path comment above.
} else {
result[id] = items
}
mu.Unlock()
}()
}
wg.Wait()
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
// batchResolveMergeForwardSenders gathers every sub-item across every
// prefetched merge_forward and runs a single ResolveSenderNames call against
// nameCache. No-op when nameCache is nil (callers that pre-fetched without
// caring about sender resolution, e.g. event subscribers that render on the
// fly) or when nothing was fetched.
func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch map[string][]map[string]interface{}, nameCache map[string]string) {
if nameCache == nil || len(prefetch) == 0 {
return
}
var allSubItems []map[string]interface{}
for _, items := range prefetch {
allSubItems = append(allSubItems, items...)
}
if len(allSubItems) == 0 {
return
}
ResolveSenderNames(runtime, allSubItems, nameCache)
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward
// container via a single API call. Returns a flat list of raw message items
// with upper_message_id for tree reconstruction.
//
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
// — earlier this used the low-level DoAPI and reported every non-zero code
// as a generic "empty data" error, hiding the real failure (e.g. a server
// "code: 2200 Internal Error" with its log_id would show up as just "empty
// data" in the output).
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: mergeForwardMessagesPath(messageID),
QueryParams: larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
},
})
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
}, nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, fmt.Errorf("invalid response: %w", err)
}
data, _ := result["data"].(map[string]interface{})
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
// has `code: 0` but omits `data` entirely, that field comes back as nil.
// Reading from a nil map in Go is safe (returns the zero value, never
// panics), but guarding explicitly makes the "successful empty
// response" path obvious and keeps a future signature change from
// silently introducing nil-deref hazards.
if data == nil {
return nil, fmt.Errorf("empty data")
return []map[string]interface{}{}, nil
}
rawItems, _ := data["items"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, raw := range rawItems {

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
)
@@ -86,7 +87,14 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
})
t.Run("empty data", func(t *testing.T) {
t.Run("empty data treated as no children", func(t *testing.T) {
// `code: 0` with no data field is a successful "no children" response
// after the switch to DoAPIJSON (which checks the response envelope's
// code/msg directly). Previously this was reported as a generic
// "empty data" error — which also masked real failures like a
// non-zero code with data: null — so a successful empty payload now
// returns (nil, nil) and lets Convert fall through to its summary
// fallback string.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad"):
@@ -96,11 +104,193 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
}))
_, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err == nil || !strings.Contains(err.Error(), "empty data") {
t.Fatalf("fetchMergeForwardSubMessages() error = %v", err)
items, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err != nil {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) err = %v, want nil", err)
}
if len(items) != 0 {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) items = %#v, want empty", items)
}
})
t.Run("non-zero code surfaces real error", func(t *testing.T) {
// Regression coverage for the bug that motivated the DoAPIJSON
// switch: a server response with code != 0 (here: 2200 Internal
// Error, observed in production for some merge_forward IDs) used to
// be silently reported as the generic "empty data" string, hiding
// the real code/msg/log_id. With DoAPIJSON the envelope's code is
// checked and surfaced as an ErrAPI containing the real message.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
_, err := fetchMergeForwardSubMessages("om_err", runtime)
if err == nil {
t.Fatal("fetchMergeForwardSubMessages(code=2200) err = nil, want non-nil")
}
if !strings.Contains(err.Error(), "Internal Error") {
t.Fatalf("fetchMergeForwardSubMessages(code=2200) err = %q, want it to contain the real msg", err)
}
})
}
// TestPrefetchMergeForwardSubItems exercises the bounded-concurrency prefetch
// path: each merge_forward in the input gets its own GET fetched in
// parallel, and the returned map keys items by their merge_forward
// message_id. A goroutine cross-contamination bug would manifest as
// mis-keyed entries.
func TestPrefetchMergeForwardSubItems(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Each merge_forward's path ends with its message_id; key the
// returned child off that so the test can detect mis-attachment.
path := req.URL.Path
// The path looks like /open-apis/im/v1/messages/<encoded-id>; take
// the last segment.
lastSlash := strings.LastIndex(path, "/")
if lastSlash < 0 {
return nil, fmt.Errorf("unexpected path: %s", path)
}
hostID := path[lastSlash+1:]
mu.Lock()
callCount++
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"message_id": "om_child_of_" + hostID,
"create_time": "1710500000000",
},
},
},
}), nil
}))
// Mix of merge_forward and non-merge_forward messages — only the former
// should be fetched. 5 merge_forwards is enough to exercise the
// bounded fan-out (cap = 4) rather than fall into a single-message fast
// path.
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_text_a", "msg_type": "text"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_3", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_image", "msg_type": "image"},
map[string]interface{}{"message_id": "om_mf_4", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_5", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
if callCount != 5 {
t.Fatalf("expected 5 merge_forward fetches, got %d", callCount)
}
wantIDs := []string{"om_mf_1", "om_mf_2", "om_mf_3", "om_mf_4", "om_mf_5"}
for _, id := range wantIDs {
children, ok := got[id]
if !ok {
t.Fatalf("prefetch map missing key %q (cross-thread contamination?)", id)
}
if len(children) != 1 {
t.Fatalf("prefetch[%s] children len = %d, want 1", id, len(children))
}
want := "om_child_of_" + id
if children[0]["message_id"] != want {
t.Fatalf("prefetch[%s] child id = %v, want %q — mis-attributed result", id, children[0]["message_id"], want)
}
}
for _, missing := range []string{"om_text_a", "om_image"} {
if _, ok := got[missing]; ok {
t.Fatalf("prefetch map should not contain non-merge_forward key %q", missing)
}
}
}
// TestPrefetchMergeForwardSubItemsHTTPError covers the transport-level
// failure path: server replies with a non-2xx status (e.g. 503). DoAPIJSON
// surfaces this as a network error, the prefetch goroutine emits a stderr
// warning, and — critically — does NOT insert the failed id into the
// result map, so Convert falls back to inline retry (same contract as
// envelope-level errors, exercised by
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch).
func TestPrefetchMergeForwardSubItemsHTTPError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// 503 Service Unavailable with no body — purely a transport-layer
// error. DoAPIJSON's `resp.StatusCode >= 400` branch handles this
// before it ever tries to parse an envelope, which is the path the
// envelope-error test doesn't reach.
return convertlibJSONResponse(503, map[string]interface{}{}), nil
}))
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_a", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_b", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
for _, id := range []string{"om_mf_a", "om_mf_b"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains transport-error id %q — Convert would render an empty tree instead of falling back to the inline retry path", id)
}
}
}
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch is a
// regression test for the silent-empty-tree bug: when a prefetch fails, the
// failed id MUST be absent from the returned map (not present-with-nil).
// Otherwise Convert's "if cached, ok := m[id]; ok { renderTree(cached) }"
// path hits `ok=true, cached=nil`, renders an empty <forwarded_messages>
// tree, and the user-visible "[Merged forward: fetch failed: ...]" string
// that the inline path produced disappears.
func TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch(t *testing.T) {
// Mock: every fetch returns an API error.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
// Multiple ids so we hit the concurrent path (the single-id fast path
// has its own dedicated branch; covering the concurrent branch is more
// stringent since the bug originally hid inside its mu.Lock section).
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
// Every failed id MUST be absent from the map (not present-with-nil).
for _, id := range []string{"om_mf_1", "om_mf_2"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains failed id %q — this would cause Convert to render an empty <forwarded_messages> tree instead of falling back to the inline-fetch error path", id)
}
}
// And as the downstream effect: invoking the converter on the failed id
// with the (now-cleanly-absent-key) prefetch map must produce the
// inline-path error string, not an empty tree. The mocked inline fetch
// also errors with the same 2200 / Internal Error, so the rendered
// content should contain "Merged forward: fetch failed".
out := (mergeForwardConverter{}).Convert(&ConvertContext{
MessageID: "om_mf_1",
Runtime: runtime,
SenderNames: map[string]string{},
MergeForwardSubItems: got,
})
if !strings.Contains(out, "Merged forward: fetch failed") {
t.Fatalf("Convert output after prefetch failure = %q, want it to contain \"Merged forward: fetch failed\" — failure signal lost", out)
}
}
func TestMergeForwardConverterWithRuntime(t *testing.T) {

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"fmt"
"io"
"net/http"
"sync"
"github.com/larksuite/cli/shortcuts/common"
)
// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
// length for POST /im/v1/messages/reactions/batch_query (see
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
const reactionsBatchQueryMaxQueries = 20
// reactionsBatchQueryConcurrency caps in-flight batch_query requests. A single
// batch_query call is observed at ~700ms RTT regardless of payload size, so a
// fully serial loop turns N=550 (page-size 50 + 500 expanded thread_replies)
// into ~20s of latency and lets outer wrappers (agents, shells with a wall
// clock) time the whole command out. Bounded concurrency cuts that to ~5s
// without risking the server's gateway-layer 50/s + 1000/min ceiling: even at
// the worst sustained pattern (28 batches at 4-way fan-out finishing every
// ~700ms) the effective rate stays well under 6/s.
const reactionsBatchQueryConcurrency = 4
// EnrichReactions enriches messages with their reactions by calling the
// im.reactions.batch_query API. Messages are modified in place: each message
// that the server returns reactions for gets a "reactions" map attached.
//
// Failure modes (warning to stderr + skip; never aborts main message output):
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
// each message in the failed batch is marked with "reactions_error": true
// so callers can distinguish "fetch failed" from "no reactions exist".
// - batch_query returns a partial result: only messages the server failed on
// get "reactions_error": true; the successful ones get the reactions block.
//
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
// thread.go so downstream consumers handle both enrichment failures uniformly.
//
// Output shape (only on messages that the server actually returned data for):
//
// "reactions": {
// "counts": [{"reaction_type": "SMILE", "count": 3}],
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
// "operator": {...}, "action_time": "..."}]
// }
//
// The server caps queries[] at 20 per call, so messages are split into
// batches of size <= 20 before invoking the API.
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
if len(messages) == 0 {
return
}
// Index messages by ID so we can merge reactions back later.
// A single message_id may appear more than once (e.g. mget --message-ids
// om_a,om_a); every occurrence must receive the reactions block, but the
// API should only be queried once per distinct id.
// Walks into msg["thread_replies"] recursively so replies attached by
// ExpandThreadReplies are enriched in the same batched call as their parent.
idIndex := make(map[string][]map[string]interface{}, len(messages))
var ids []string
collectMessageNodes(messages, idIndex, &ids)
if len(ids) == 0 {
return
}
// Slice the id list into batches of <= reactionsBatchQueryMaxQueries.
var batches [][]string
for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
end := i + reactionsBatchQueryMaxQueries
if end > len(ids) {
end = len(ids)
}
batches = append(batches, ids[i:end])
}
// Single-batch fast path: no goroutine overhead, fully deterministic
// stderr ordering, identical behavior to the original serial loop.
if len(batches) == 1 {
fetchReactionsBatch(runtime, batches[0], idIndex, nil)
return
}
// Multi-batch path: bounded-concurrency fan-out. Safety invariant:
// collectMessageNodes dedups ids on first-seen (the `if _, seen :=
// idIndex[id]; !seen` check above), so the slice ids — and therefore
// every batch[i:end] sub-slice we hand to a goroutine — contains each
// id at most once. Different batches operate on disjoint id sets,
// which means different idIndex buckets, which means different
// message-map pointers. Goroutines never write to the same map. The
// shared mutex serializes only the stderr warning lines so they don't
// interleave between goroutines. (Race detector verifies; see
// TestEnrichReactions_DuplicateMessageID and
// TestEnrichReactions_MultiBatchCorrectness for the round-trip.)
var stderrMu sync.Mutex
sem := make(chan struct{}, reactionsBatchQueryConcurrency)
var wg sync.WaitGroup
for _, batch := range batches {
// Add(1) before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event, and
// putting it ahead of the blocking sem read keeps the parent
// goroutine's bookkeeping monotonic.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
fetchReactionsBatch(runtime, batch, idIndex, &stderrMu)
}()
}
wg.Wait()
}
// collectMessageNodes walks messages (and any nested thread_replies) and
// records each map under its message_id. Distinct ids are appended to *ids in
// first-seen order so the API is queried at most once per id.
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
for _, msg := range messages {
if id, _ := msg["message_id"].(string); id != "" {
if _, seen := idIndex[id]; !seen {
*ids = append(*ids, id)
}
idIndex[id] = append(idIndex[id], msg)
}
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
// or as []interface{} (e.g. when produced via JSON round-trip).
switch nested := msg["thread_replies"].(type) {
case []map[string]interface{}:
collectMessageNodes(nested, idIndex, ids)
case []interface{}:
typed := make([]map[string]interface{}, 0, len(nested))
for _, raw := range nested {
if m, ok := raw.(map[string]interface{}); ok {
typed = append(typed, m)
}
}
collectMessageNodes(typed, idIndex, ids)
}
}
}
// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
// and merges the results into idIndex. Failures are logged to stderr without
// aborting subsequent batches.
//
// stderrMu is non-nil in the multi-batch concurrent path (serializes warning
// lines so they don't interleave) and nil in the single-batch fast path.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}, stderrMu *sync.Mutex) {
queries := make([]map[string]interface{}, 0, len(batchIDs))
for _, id := range batchIDs {
queries = append(queries, map[string]interface{}{"message_id": id})
}
data, err := runtime.DoAPIJSON(http.MethodPost,
"/open-apis/im/v1/messages/reactions/batch_query",
nil,
map[string]interface{}{"queries": queries},
)
if err != nil {
warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}
countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])
// Attach the merged reactions block to every message that had any data.
// Each id may map to >1 message map (duplicate input), so iterate the slice.
for _, id := range batchIDs {
msgs := idIndex[id]
if len(msgs) == 0 {
continue
}
counts := countsByMsg[id]
details := detailsByMsg[id]
if len(counts) == 0 && len(details) == 0 {
continue
}
block := make(map[string]interface{}, 2)
if len(counts) > 0 {
block["counts"] = counts
}
if len(details) > 0 {
block["details"] = details
}
for _, msg := range msgs {
msg["reactions"] = block
}
}
// Surface per-message failures from the API response.
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
var failedIDs []string
for _, raw := range fails {
item, _ := raw.(map[string]interface{})
if id, _ := item["message_id"].(string); id != "" {
failedIDs = append(failedIDs, id)
}
}
if len(failedIDs) > 0 {
warnReactionsf(stderrMu, runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
}
}
}
// warnReactionsf writes a stderr warning under the supplied mutex when one is
// provided (multi-batch concurrent path), so concurrent goroutines can't
// interleave partial lines. mu == nil means the caller is on the single-batch
// fast path where no synchronization is needed.
func warnReactionsf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
if mu != nil {
mu.Lock()
defer mu.Unlock()
}
fmt.Fprintf(w, format, args...)
}
// markReactionsError flags every message map indexed under the given ids with
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
// from "no reactions exist" by reading stdout alone.
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
for _, id := range ids {
for _, msg := range idIndex[id] {
msg["reactions_error"] = true
}
}
}
func groupReactionCounts(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["reaction_count"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}
func groupReactionDetails(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["message_reaction_items"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}

View File

@@ -0,0 +1,410 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strings"
"sync"
"testing"
)
// TestEnrichReactions_Success exercises the basic happy path: messages that
// carry reactions get a "reactions" field, messages without reactions stay
// untouched.
func TestEnrichReactions_Success(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
var payload map[string]interface{}
body, _ := io.ReadAll(req.Body)
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries size = %d, want 2", len(queries))
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 3},
},
},
},
"success_msg_reaction_details": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"message_reaction_items": []interface{}{
map[string]interface{}{
"reaction_id": "react_1",
"emoji_type": "SMILE",
"operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"},
"action_time": "1710600000",
},
},
},
},
"fail_msg_reaction_details": []interface{}{},
},
}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
reactionsA, ok := messages[0]["reactions"].(map[string]interface{})
if !ok {
t.Fatalf("message om_a missing reactions field: %#v", messages[0])
}
counts, _ := reactionsA["counts"].([]interface{})
if len(counts) != 1 {
t.Fatalf("om_a counts = %d, want 1", len(counts))
}
details, _ := reactionsA["details"].([]interface{})
if len(details) != 1 {
t.Fatalf("om_a details = %d, want 1", len(details))
}
if _, ok := messages[1]["reactions"]; ok {
t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1])
}
}
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
// max for batch_query). Multi-batch dispatch is concurrent (bounded fan-out),
// so callers must tolerate any ordering of batch arrivals at the transport.
func TestEnrichReactions_BatchSize(t *testing.T) {
var mu sync.Mutex
var observedBatchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
mu.Lock()
observedBatchSizes = append(observedBatchSizes, len(queries))
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
}), nil
}))
messages := make([]map[string]interface{}, 25)
for i := range messages {
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)}
}
EnrichReactions(runtime, messages)
sort.Ints(observedBatchSizes)
if want := []int{5, 20}; !reflect.DeepEqual(observedBatchSizes, want) {
t.Fatalf("batch sizes (sorted) = %v, want %v", observedBatchSizes, want)
}
}
// TestEnrichReactions_MultiBatchCorrectness exercises the bounded-concurrency
// multi-batch path: every message across all batches must receive its own
// reactions block regardless of which goroutine the batch ran on. A race or a
// cross-batch index mix-up would manifest as missing or duplicated blocks.
func TestEnrichReactions_MultiBatchCorrectness(t *testing.T) {
var mu sync.Mutex
var batchCalls int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
counts := make([]interface{}, 0, len(queries))
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
counts = append(counts, map[string]interface{}{
"message_id": id,
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
})
}
mu.Lock()
batchCalls++
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": counts,
},
}), nil
}))
// 65 messages -> 4 batches (20+20+20+5), enough to actually exercise the
// bounded fan-out (concurrency cap = 4) rather than degenerate to 1-2 calls.
messages := make([]map[string]interface{}, 65)
for i := range messages {
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%03d", i)}
}
EnrichReactions(runtime, messages)
if batchCalls != 4 {
t.Fatalf("expected 4 batched calls, got %d", batchCalls)
}
for i, m := range messages {
if _, ok := m["reactions"]; !ok {
t.Fatalf("message %d (%s) missing reactions after multi-batch run", i, m["message_id"])
}
}
}
// TestEnrichReactions_APIFailure: when the API call fails, messages stay
// without a reactions field but get marked with reactions_error=true so
// downstream consumers can distinguish "fetch failed" from "no reactions".
// Mirrors the thread_replies_error pattern in thread.go.
func TestEnrichReactions_APIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("simulated network error")
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
for _, m := range messages {
if _, ok := m["reactions"]; ok {
t.Fatalf("message %v should have no reactions after API failure", m["message_id"])
}
if v, _ := m["reactions_error"].(bool); !v {
t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v",
m["message_id"], m["reactions_error"])
}
}
}
// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry
// for one ID, that message gets reactions_error=true while the rest stay
// clean (no error flag) and keep their normal reactions block.
func TestEnrichReactions_PartialFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_ok",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
},
"fail_msg_reaction_details": []interface{}{
map[string]interface{}{"message_id": "om_bad"},
},
},
}), nil
}))
ok := map[string]interface{}{"message_id": "om_ok"}
bad := map[string]interface{}{"message_id": "om_bad"}
EnrichReactions(runtime, []map[string]interface{}{ok, bad})
if _, has := ok["reactions"]; !has {
t.Fatalf("om_ok should have reactions: %#v", ok)
}
if v, _ := ok["reactions_error"].(bool); v {
t.Fatalf("om_ok must not carry reactions_error: %#v", ok)
}
if _, has := bad["reactions"]; has {
t.Fatalf("om_bad should have no reactions block: %#v", bad)
}
if v, _ := bad["reactions_error"].(bool); !v {
t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"])
}
}
// TestEnrichReactions_EmptyMessages: no messages -> no API call at all.
func TestEnrichReactions_EmptyMessages(t *testing.T) {
called := false
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
EnrichReactions(runtime, nil)
EnrichReactions(runtime, []map[string]interface{}{})
if called {
t.Fatalf("API should not be called when messages list is empty")
}
}
// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id
// (defensive) should not crash and not be sent in queries.
func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) {
var sentIDs []string
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
sentIDs = append(sentIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{}, // no message_id
{"message_id": ""},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) {
t.Fatalf("sent IDs = %v, want %v", sentIDs, want)
}
}
// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent
// message must also be enriched, in the same batch_query call as the parent —
// otherwise the parent gets reactions but its replies don't, leaving the output
// inconsistent.
func TestEnrichReactions_WalksThreadReplies(t *testing.T) {
var observedQueriedIDs []string
var observedCallCount int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
observedCallCount++
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
observedQueriedIDs = append(observedQueriedIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_top",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
map[string]interface{}{
"message_id": "om_reply1",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2},
},
},
map[string]interface{}{
"message_id": "om_reply2",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "HEART", "count": 3},
},
},
},
},
}), nil
}))
reply1 := map[string]interface{}{"message_id": "om_reply1"}
reply2 := map[string]interface{}{"message_id": "om_reply2"}
top := map[string]interface{}{
"message_id": "om_top",
"thread_replies": []map[string]interface{}{reply1, reply2},
}
messages := []map[string]interface{}{top}
EnrichReactions(runtime, messages)
if observedCallCount != 1 {
t.Fatalf("expected 1 batched API call, got %d", observedCallCount)
}
sort.Strings(observedQueriedIDs)
if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) {
t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want)
}
if _, ok := top["reactions"]; !ok {
t.Fatalf("top message missing reactions")
}
if _, ok := reply1["reactions"]; !ok {
t.Fatalf("reply1 missing reactions — thread_replies were not walked")
}
if _, ok := reply2["reactions"]; !ok {
t.Fatalf("reply2 missing reactions — thread_replies were not walked")
}
}
// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct
// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a),
// both maps must receive the same reactions block, and the API must be queried
// for the id only once.
func TestEnrichReactions_DuplicateMessageID(t *testing.T) {
var observedQueriesPerCall []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
observedQueriesPerCall = append(observedQueriesPerCall, len(queries))
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 2},
},
},
},
},
}), nil
}))
first := map[string]interface{}{"message_id": "om_a"}
second := map[string]interface{}{"message_id": "om_a"}
other := map[string]interface{}{"message_id": "om_b"}
messages := []map[string]interface{}{first, other, second}
EnrichReactions(runtime, messages)
if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) {
t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want)
}
firstReactions, firstOK := first["reactions"]
secondReactions, secondOK := second["reactions"]
if !firstOK {
t.Fatalf("first om_a entry missing reactions")
}
if !secondOK {
t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped")
}
if !reflect.DeepEqual(firstReactions, secondReactions) {
t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions)
}
}

View File

@@ -6,6 +6,7 @@ package convertlib
import (
"fmt"
"net/http"
"sync"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -17,10 +18,55 @@ const ThreadRepliesPerThread = 50
// ThreadRepliesTotalLimit is the default max total thread replies across all threads.
const ThreadRepliesTotalLimit = 500
// threadRepliesFetchConcurrency caps in-flight per-thread GET /messages calls
// when expanding multiple threads in one shortcut invocation. Each call is a
// per-thread RTT (~1s observed), so a strictly serial loop turns N=10 thread
// roots into ~10s of latency — the same multiplier that motivated the
// reactions enrichment fan-out. GET /messages has no published per-app
// rate-limit anywhere near these levels, so we set this higher than the
// reactions batch_query cap (which sits at 4 to stay well under the
// gateway-layer 50/s + 1000/min explicit ceiling on the reactions endpoint).
const threadRepliesFetchConcurrency = 8
// ExpandThreadReplies fetches and embeds thread replies for messages that contain a thread_id.
// For each unique thread_id found in messages, it fetches up to perThread replies (asc order)
// and attaches them as "thread_replies" on the message. Expansion stops once totalLimit
// cumulative replies have been fetched. nameCache is the shared open_id→name map.
// and attaches them as "thread_replies" on the first outer message that referenced that thread.
// Expansion stops once totalLimit cumulative replies have been allocated across planned fetches.
// nameCache is the shared open_id→name map.
//
// Implementation is two-phase:
//
// 1. Plan + concurrent fetch. Walk messages in order, recording every
// unique thread_id with a fetch limit of perThread (no upfront budget
// deduction — see below). Then dispatch the planned fetches with
// bounded concurrency; each goroutine writes only to its own result
// slot, no shared mutable state besides that slot.
//
// 2. Sequential attach with post-hoc budget enforcement. Walk the planned
// threads in their original first-seen order, accumulating actual
// returned reply counts against totalLimit. When a thread's actual
// replies would push the running total past totalLimit, its reply slice
// is truncated to fit the remaining budget and thread_has_more is set
// on its host so consumers know more replies exist server-side. Threads
// that arrive past a fully-exhausted budget keep their thread_id on the
// host but don't get thread_replies attached (semantically identical to
// the pre-existing serial behavior for over-budget threads). The phase
// stays single-threaded because ResolveSenderNames writes to the shared
// nameCache and FormatMessageItem may trigger merge_forward expansion
// that also touches nameCache.
//
// Budget semantics match the pre-existing serial implementation exactly:
// each thread's actual returned count is what gets deducted from the
// budget, not its planned per-thread ceiling. An earlier draft of this
// refactor allocated the budget against the planned ceiling upfront for
// implementation simplicity, but that silently dropped later threads in
// chats where many threads return well under perThread replies (e.g.
// totalLimit=500 + perThread=50 + 12 short threads of 3 replies each → old
// code attached all 12, planned-allocation code attached only 10). The
// trade-off here is a small amount of server-side over-fetching for
// threads that will end up truncated or dropped — bounded by perThread per
// thread — in exchange for preserving the original "every thread that fits
// gets its data" guarantee.
func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]interface{}, nameCache map[string]string, perThread, totalLimit int) {
if runtime == nil {
return
@@ -35,52 +81,161 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
totalLimit = ThreadRepliesTotalLimit
}
totalFetched := 0
// Phase 1a: enumerate every unique thread_id in first-seen order. We
// deliberately do NOT deduct anything from the totalLimit budget here —
// see the godoc above and the Phase 2 truncation step. The first outer
// message referencing a given thread_id is the host that will receive
// the thread_replies attachment, matching the pre-existing behavior
// where duplicates inherited nothing.
type plan struct {
threadID string
limit int
host map[string]interface{}
}
var plans []plan
seen := make(map[string]bool)
for _, msg := range messages {
if totalFetched >= totalLimit {
break
}
tid, _ := msg["thread_id"].(string)
if tid == "" || seen[tid] {
continue
}
seen[tid] = true
plans = append(plans, plan{threadID: tid, limit: perThread, host: msg})
}
if len(plans) == 0 {
return
}
limit := perThread
if remaining := totalLimit - totalFetched; limit > remaining {
limit = remaining
// Phase 1b: concurrent fetch. Each goroutine writes only to its own
// results[i] slot, so there is no shared mutable state besides that
// slot. The single-batch fast path skips goroutine setup for clarity
// and to keep "one thread root" behavior identical to the old code.
type result struct {
rawReplies []map[string]interface{}
hasMore bool
err error
}
results := make([]result, len(plans))
if len(plans) == 1 {
items, hasMore, err := fetchThreadReplies(runtime, plans[0].threadID, plans[0].limit)
results[0] = result{rawReplies: items, hasMore: hasMore, err: err}
} else {
sem := make(chan struct{}, threadRepliesFetchConcurrency)
var wg sync.WaitGroup
for i, p := range plans {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, hasMore, err := fetchThreadReplies(runtime, p.threadID, p.limit)
results[i] = result{rawReplies: items, hasMore: hasMore, err: err}
}()
}
wg.Wait()
}
rawReplies, hasMore, fetchErr := fetchThreadReplies(runtime, tid, limit)
if fetchErr != nil {
// Preserve the outer message while surfacing that thread expansion failed.
msg["thread_replies_error"] = true
// Phase 2a-pre: apply the totalLimit budget against actual returned
// counts (not planned ceilings) and trim each result in place. Walking
// in original plan order matches the pre-existing serial behavior so a
// chat with budget-exceeding total replies cuts off at the same thread
// position as the old code. Threads past a fully-drained budget have
// their slice cleared to an empty (non-nil) slice — distinct from a
// fetch error's nil rawReplies — so the attach loop below leaves the
// host alone without flagging thread_replies_error. Threads whose
// actual count crosses the boundary get their slice truncated and
// hasMore flagged so consumers know more exist server-side.
remaining := totalLimit
for i := range plans {
r := &results[i]
if r.err != nil || len(r.rawReplies) == 0 {
continue
}
// Successful fetches always return a non-nil (possibly empty) slice.
// A nil slice indicates thread expansion did not complete.
if rawReplies == nil {
msg["thread_replies_error"] = true
if remaining <= 0 {
// Budget already drained by earlier threads — discard this
// thread's fetched replies. We over-fetched on the wire (one
// of the explicit trade-offs documented on the function), but
// the user-visible output remains the same as the serial
// implementation, which would never have issued this fetch.
// Empty slice (not nil) so the attach loop treats this like
// "successfully returned no replies", not "fetch failed".
r.rawReplies = r.rawReplies[:0]
continue
}
if len(rawReplies) == 0 {
continue
if len(r.rawReplies) > remaining {
r.rawReplies = r.rawReplies[:remaining]
r.hasMore = true
}
remaining -= len(r.rawReplies)
}
replies := make([]map[string]interface{}, 0, len(rawReplies))
for _, r := range rawReplies {
replies = append(replies, FormatMessageItem(r, runtime, nameCache))
// Phase 2a-merge: collect every (post-truncation) raw reply across all
// threads and pre-fetch merge_forward sub-messages for the ones that
// need it. Without this, a thread reply that is itself a merge_forward
// would trigger another serial GET inside FormatMessageItem —
// re-introducing the same N × RTT stall pattern that Phase 1b just
// removed.
var allRawReplies []interface{}
for i := range plans {
r := results[i]
if len(r.rawReplies) == 0 {
continue
}
for _, raw := range r.rawReplies {
allRawReplies = append(allRawReplies, raw)
}
}
mergePrefetch := PrefetchMergeForwardSubItems(runtime, allRawReplies, nameCache)
// Phase 2a: format every plan's replies sequentially. FormatMessageItem
// may still touch nameCache for non-merge_forward content types
// (e.g. mention resolution), so this stays single-threaded — concurrent
// writes to nameCache would race.
preparedReplies := make([][]map[string]interface{}, len(plans))
for i, p := range plans {
r := results[i]
if r.err != nil || r.rawReplies == nil {
p.host["thread_replies_error"] = true
continue
}
if len(r.rawReplies) == 0 {
continue
}
replies := make([]map[string]interface{}, 0, len(r.rawReplies))
for _, raw := range r.rawReplies {
replies = append(replies, FormatMessageItemWithMergePrefetch(raw, runtime, nameCache, mergePrefetch))
}
preparedReplies[i] = replies
}
// Phase 2b: one batched ResolveSenderNames across all replies from all
// threads. The pre-existing per-thread call pattern would issue a fresh
// contact API request for every thread that introduced a new sender,
// turning N threads into up to N serial contact RTTs even after the
// fetches themselves went parallel. Consolidating into a single call
// resolves every still-missing open_id in one request and lets the
// nameCache absorb the rest.
var combined []map[string]interface{}
for _, replies := range preparedReplies {
combined = append(combined, replies...)
}
if len(combined) > 0 {
ResolveSenderNames(runtime, combined, nameCache)
}
// Phase 2c: attach the (now name-resolved) replies to their hosts.
for i, p := range plans {
replies := preparedReplies[i]
if replies == nil {
continue
}
ResolveSenderNames(runtime, replies, nameCache)
AttachSenderNames(replies, nameCache)
msg["thread_replies"] = replies
if hasMore {
msg["thread_has_more"] = true
p.host["thread_replies"] = replies
if results[i].hasMore {
p.host["thread_has_more"] = true
}
totalFetched += len(rawReplies)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
)
@@ -89,6 +90,201 @@ func TestFetchThreadRepliesError(t *testing.T) {
}
}
// TestExpandThreadRepliesMultiThreadConcurrent exercises the bounded-concurrency
// multi-thread path: every distinct thread_id gets its own GET fetched in
// parallel, and the right replies land on the right outer host (the *first*
// outer message that referenced each thread_id). A race or cross-thread
// result mix-up would manifest as missing / mis-attached replies.
func TestExpandThreadRepliesMultiThreadConcurrent(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
tid := req.URL.Query().Get("container_id")
mu.Lock()
callCount++
mu.Unlock()
// Return one synthetic reply per thread, tagged with the thread id so
// we can assert that the right replies landed on the right host.
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_" + tid,
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"reply for ` + tid + `"}`},
},
},
},
}), nil
}))
// 5 distinct thread roots → 5 planned fetches, dispatched under the
// concurrency cap. Enough to actually exercise the bounded fan-out
// rather than degenerate to the single-thread fast path.
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_a"},
{"message_id": "om_root_2", "thread_id": "omt_b"},
{"message_id": "om_root_3", "thread_id": "omt_c"},
{"message_id": "om_root_4", "thread_id": "omt_d"},
{"message_id": "om_root_5", "thread_id": "omt_e"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 500)
if callCount != 5 {
t.Fatalf("expected 5 thread fetches, got %d", callCount)
}
for i, m := range messages {
tid := m["thread_id"].(string)
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("message %d (thread %s) missing thread_replies: %#v", i, tid, m)
}
if len(replies) != 1 {
t.Fatalf("message %d (thread %s) replies len = %d, want 1", i, tid, len(replies))
}
// Each thread's reply was tagged with its own thread_id; verify no
// goroutine cross-contamination.
gotTid, _ := replies[0]["thread_id"].(string)
if gotTid != tid {
t.Fatalf("message %d (thread %s) got reply tagged with thread_id=%q — cross-thread contamination",
i, tid, gotTid)
}
}
}
// TestExpandThreadRepliesTotalLimitUsesActualCounts is a regression test for
// the budget-allocation refactor: the new concurrent path must deduct
// totalLimit using the *actual* returned reply count per thread, not the
// planned per-thread ceiling. Otherwise chats with many low-volume threads
// (very common — most threads in a busy group have just a few replies)
// silently drop later threads when the planned ceilings sum past totalLimit
// well before the actual replies do.
func TestExpandThreadRepliesTotalLimitUsesActualCounts(t *testing.T) {
// Synthetic API: every thread returns exactly 3 replies, regardless of
// the requested page_size. This is the "short threads" scenario where
// the difference between planned-ceiling and actual-count budget
// accounting becomes visible.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 3)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 12 distinct thread roots × 3 actual replies each = 36 total. With
// perThread=50 (the default ceiling), the old "deduct planned ceiling"
// implementation would have exhausted totalLimit=100 after just 2
// threads (2 × 50 = 100) and silently skipped the remaining 10. The
// correct behavior deducts actual counts (12 × 3 = 36 < 100), so all
// 12 threads should attach.
messages := make([]map[string]interface{}, 12)
for i := range messages {
messages[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_root_%02d", i),
"thread_id": fmt.Sprintf("omt_%02d", i),
}
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 50, 100)
for i, m := range messages {
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("thread %d (%s) silently dropped — thread_replies missing despite actual budget headroom",
i, m["thread_id"])
}
if len(replies) != 3 {
t.Fatalf("thread %d (%s) replies len = %d, want 3", i, m["thread_id"], len(replies))
}
}
}
// TestExpandThreadRepliesTruncatesOnBudgetBoundary covers the cross-boundary
// case: a thread whose actual replies straddle the remaining budget gets
// its slice truncated to fit and thread_has_more flagged so consumers know
// more exist server-side.
func TestExpandThreadRepliesTruncatesOnBudgetBoundary(t *testing.T) {
// Every thread returns exactly 4 replies.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 4)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 3 threads × 4 replies = 12, but totalLimit = 10. So:
// - thread 0 fully attached (4 replies; running total 4)
// - thread 1 fully attached (4 replies; running total 8)
// - thread 2 truncated to 2 replies (running total 10), has_more=true
// - any thread 3+ would be dropped entirely
messages := []map[string]interface{}{
{"message_id": "om_root_0", "thread_id": "omt_0"},
{"message_id": "om_root_1", "thread_id": "omt_1"},
{"message_id": "om_root_2", "thread_id": "omt_2"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 10)
for i, want := range []int{4, 4, 2} {
replies, _ := messages[i]["thread_replies"].([]map[string]interface{})
if len(replies) != want {
t.Fatalf("thread %d replies len = %d, want %d (post-budget truncation)", i, len(replies), want)
}
}
if messages[2]["thread_has_more"] != true {
t.Fatalf("thread 2 was truncated by budget but thread_has_more = %#v, want true",
messages[2]["thread_has_more"])
}
// And the truncated host must NOT be flagged with thread_replies_error —
// budget truncation is success, not failure.
for i, m := range messages {
if v, _ := m["thread_replies_error"].(bool); v {
t.Fatalf("message %d incorrectly flagged with thread_replies_error after budget truncation: %#v", i, m)
}
}
}
func TestExpandThreadRepliesMarksFetchError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {

View File

@@ -543,7 +543,17 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) {
if offset+16 > end {
return -1, -1
}
boxEnd = int(binary.BigEndian.Uint64(data[offset+8:]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring the
// offset+size used for 32-bit boxes below). Reject sizes that do not
// fit the search window; this also rejects values that would
// overflow int and drive boxEnd negative (CWE-190), which would
// otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8:])
if largesize < 16 || largesize > uint64(end-offset) {
return -1, -1
}
boxEnd = offset + int(largesize)
dataStart = offset + 16
default:
if size < 8 {
@@ -688,7 +698,16 @@ func readMp4DurationBytes(data []byte) int64 {
if offset+16 > fileSize {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190), which would otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8 : offset+16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0
@@ -749,7 +768,16 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
if _, err := f.ReadAt(hdr[8:16], offset+8); err != nil {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(hdr[8:16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190).
largesize := binary.BigEndian.Uint64(hdr[8:16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -15,13 +16,31 @@ import (
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// bot_strip_p2p is the request-level adjustment notice emitted when bot
// identity receives a mixed --types containing "p2p": the p2p value is
// removed from the outgoing query (which the API would otherwise reject)
// and the caller is informed via a stderr warning + a structured entry
// in outData["notices"]. This is a notice, not a filter — it lives in a
// separate slot from outData["filter"] so the two never collide.
const (
botStripP2pCode = "bot_strip_p2p"
botStripP2pMessage = "To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p."
)
// writeBotStripP2pWarning prints the bot_strip_p2p adjustment to stderr in
// the repo's standard "warning: <code>: <message>" form (matches the format
// used in shortcuts/common/runner.go's unknown-format fallback).
func writeBotStripP2pWarning(errOut io.Writer) {
fmt.Fprintf(errOut, "warning: %s: %s\n", botStripP2pCode, botStripP2pMessage)
}
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -29,28 +48,53 @@ var ImChatList = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
// When bot identity strips p2p from --types, emits the same stderr warning
// Execute would emit, so DryRun output truthfully reflects what the API
// will receive (matches the shortcuts/drive/drive_search.go pattern of
// echoing request-level adjustments in both DryRun and Execute).
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
effective, stripped, _ := resolveTypes(runtime) // Validate has already guaranteed err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
Params(buildChatListParams(runtime, effective))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
// Validate enforces flag preconditions: page-size bounds, --types element
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return err
}
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
return output.ErrValidation(
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
// outData["notices"] is populated only when bot identity strips p2p from
// --types — a request-level adjustment that lives in its own slot so it
// never collides with the row-level mute filter.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
effective, stripped, _ := resolveTypes(runtime) // Validate guarantees err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
@@ -88,6 +132,11 @@ var ImChatList = common.Shortcut{
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
if stripped {
outData["notices"] = []map[string]interface{}{
{"code": botStripP2pCode, "message": botStripP2pMessage},
}
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
@@ -115,6 +164,17 @@ var ImChatList = common.Shortcut{
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
row["chat_mode"] = chatMode
if chatMode == "p2p" {
if pt, _ := m["p2p_target_type"].(string); pt != "" {
row["p2p_target_type"] = pt
}
if pid, _ := m["p2p_target_id"].(string); pid != "" {
row["p2p_target_id"] = pid
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
@@ -135,11 +195,76 @@ var ImChatList = common.Shortcut{
},
}
// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
// and repeated --types p2p --types group arrive here as a 2-element []string,
// so this function never re-splits on commas.
// Returns the normalized (lowercased, deduped, in input order) parts on success.
// Empty raw input is a no-op (returns nil, nil).
// Returns ErrValidation when any element is empty or outside {"p2p", "group"}.
func normalizeTypes(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
}
if p != "p2p" && p != "group" {
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out, nil
}
// resolveTypes layers bot identity downgrade on top of normalizeTypes.
// Under bot identity, "p2p" is stripped from the parts and the caller is
// informed (DryRun / Execute emit a stderr warning; Execute additionally
// writes a structured entry under outData["notices"]).
// Validate has already rejected "bot + parts == ['p2p']" cases, so kept is
// never empty here.
//
// Returns (effective CSV, stripped, err):
// - effective: comma-joined types to send as the API query param
// - stripped: true iff bot identity removed "p2p" from a mixed --types value
// - err: forwarded from normalizeTypes
func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return "", false, err
}
if !runtime.IsBot() {
return strings.Join(parts, ","), false, nil
}
// Bot identity: strip "p2p" so the API call succeeds with just groups.
// Validate has already rejected the "bot + only p2p" case, so kept is never empty here.
// Allocate a fresh slice (rather than aliasing parts[:0]) — parts has at most 2
// elements so the cost is negligible, and avoiding shared backing storage removes
// a class of "two slices, same array" surprises if a future caller keeps parts.
stripped := false
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p == "p2p" {
stripped = true
continue
}
kept = append(kept, p)
}
return strings.Join(kept, ","), stripped, nil
}
// buildChatListParams builds the query parameters. effectiveTypes is the
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
@@ -152,5 +277,8 @@ func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
if effectiveTypes != "" {
params["types"] = effectiveTypes
}
return params
}

View File

@@ -4,26 +4,41 @@
package im
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context.
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser)
}
// newChatListTestRuntimeContextWithIdentity is the identity-aware variant.
func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
if name == "types" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().String(name, "", "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -37,11 +52,22 @@ func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string,
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as)
// Attach a minimal Factory with IOStreams so DryRun / Execute paths that
// emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO().
// Stays pure-logic — no HTTP client, no httpmock; integration tests use
// newBotShortcutRuntime / newUserShortcutRuntime for that.
rt.Factory = &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
},
}
return rt
}
func TestBuildChatListParams_Defaults(t *testing.T) {
@@ -49,7 +75,7 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -62,6 +88,9 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
if _, present := got["types"]; present {
t.Fatalf("types should be omitted when --types is empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
@@ -71,7 +100,7 @@ func TestBuildChatListParams_Overrides(t *testing.T) {
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -126,3 +155,459 @@ func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}
func TestNormalizeTypes(t *testing.T) {
cases := []struct {
name string
raw []string
want []string
wantErr string // substring match
}{
{"empty returns nil no error", nil, nil, ""},
{"single p2p", []string{"p2p"}, []string{"p2p"}, ""},
{"single group", []string{"group"}, []string{"group"}, ""},
{"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""},
{"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""},
{"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""},
{"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""},
{"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""},
{"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"},
{"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`},
{"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := normalizeTypes(c.raw)
if c.wantErr != "" {
if err == nil {
t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err)
}
if len(got) != len(c.want) {
t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i])
}
}
})
}
}
func TestResolveTypes(t *testing.T) {
cases := []struct {
name string
raw string
as core.Identity
wantEffective string
wantStripped bool
}{
{"user empty", "", core.AsUser, "", false},
{"user p2p", "p2p", core.AsUser, "p2p", false},
{"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false},
{"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false},
{"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false},
{"bot empty", "", core.AsBot, "", false},
{"bot group only", "group", core.AsBot, "group", false},
{"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true},
{"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as)
effective, stripped, err := resolveTypes(rt)
if err != nil {
t.Fatalf("resolveTypes() unexpected err = %v", err)
}
if effective != c.wantEffective {
t.Fatalf("effective = %q; want %q", effective, c.wantEffective)
}
if stripped != c.wantStripped {
t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped)
}
})
}
}
func TestBuildChatListParams_TypesPassthrough(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "p2p,group")
if got["types"] != "p2p,group" {
t.Fatalf("types = %v; want \"p2p,group\"", got["types"])
}
}
func TestImChatList_Validate_Types(t *testing.T) {
cases := []struct {
name string
typesRaw string
as core.Identity
wantErr string // substring; "" means no error
}{
{"user empty ok", "", core.AsUser, ""},
{"user p2p ok", "p2p", core.AsUser, ""},
{"user group ok", "group", core.AsUser, ""},
{"user p2p,group ok", "p2p,group", core.AsUser, ""},
{"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"},
{"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"},
{"bot empty ok", "", core.AsBot, ""},
{"bot group ok", "group", core.AsBot, ""},
{"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""},
{"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t,
map[string]string{"types": c.typesRaw, "page-size": "20"},
nil, c.as)
err := ImChatList.Validate(context.Background(), rt)
if c.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() err = nil; want substring %q", c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr)
}
})
}
}
// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList
// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format
// is forced to "json" so Execute output lands in a parseable form on
// runtime.Factory.IOStreams.Out.
func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("user-id-type", "open_id", "")
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
cmd.Flags().StringSlice("types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("exclude-muted", false, "")
cmd.Flags().Bool("dry-run", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime.Cmd = cmd
runtime.Format = "json"
}
// chatListOutBuf retrieves the captured stdout buffer for assertions.
func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.Out to be *bytes.Buffer")
}
return buf
}
// chatListErrBuf retrieves the captured stderr buffer for assertions
// (used to verify request-level warnings like `bot_strip_p2p`).
func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer")
}
return buf
}
func TestImChatList_Execute_BotStripsP2p(t *testing.T) {
var capturedURL string
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedURL = req.URL.String()
body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !strings.Contains(capturedURL, "types=group") {
t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL)
}
if strings.Contains(capturedURL, "p2p") {
t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL)
}
// Structured notice: outData["notices"] contains a {code, message} entry
// for bot_strip_p2p (request-level adjustment, not a row-level filter).
out := chatListOutBuf(t, rt).String()
for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} {
if !strings.Contains(out, want) {
t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out)
}
}
// filter slot must remain mute-scoped: bot_strip_p2p must not leak into
// outData["filter"].applied (no priority conflict by design).
if strings.Contains(out, `"applied": "bot_strip_p2p"`) {
t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out)
}
// Stderr: matches repo `warning: <code>: <message>` convention (cf.
// shortcuts/common/runner.go unknown-format fallback).
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch
// also emits the bot_strip_p2p warning to stderr so a previewed request
// truthfully reflects what Execute would send (drive_search.go DryRun parity).
func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String())
return nil, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
dr := ImChatList.DryRun(context.Background(), rt)
if dr == nil {
t.Fatalf("DryRun returned nil")
}
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
func TestImChatList_RowRendering_P2pFields(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
// echoes back the page_token.
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":true,"page_token":"next_tok"}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
rt.Format = "pretty"
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing group-row field %q:\n%s", want, out)
}
}
for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 chat(s) listed") {
t.Fatalf("pretty output missing footer count:\n%s", out)
}
if !strings.Contains(out, "next_tok") {
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
}
}
func TestImChatList_DryRun_TypesPassthrough(t *testing.T) {
cases := []struct {
name string
as core.Identity
typesRaw string
wantSub string // substring expected in dry-run JSON
wantErr bool // whether Validate should reject before DryRun runs
}{
{"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false},
{"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false},
{"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false},
{"bot group passes", core.AsBot, "group", `"types":"group"`, false},
{"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"page-size": "20",
"types": c.typesRaw,
}, nil, c.as)
if err := ImChatList.Validate(context.Background(), rt); err != nil {
if !c.wantErr {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if c.wantErr {
t.Fatalf("Validate() err = nil; want rejection")
}
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, c.wantSub) {
t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub)
}
})
}
}
func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case).
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"},
{"chat_id":"oc_g2","name":"Group2","external":true}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, nil, nil) // no --types; default behavior
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
// chat_mode / p2p_target_* must NOT appear since the API didn't return them.
for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} {
// "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it;
// asserting the rendered table fields are missing is the goal.
// The JSON pass-through preserves whatever API returned (omitted here),
// so neither path should produce these strings.
if strings.Contains(out, forbidden) {
t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out)
}
}
// Sanity: the two chat IDs must still be present (renderer didn't crash).
for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
path := req.URL.Path
switch {
case strings.HasSuffix(path, "/im/v1/chats"):
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"):
// Mark oc_p (the p2p) as muted; oc_g not muted.
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","is_muted":false},
{"chat_id":"oc_p","is_muted":true}
]}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
t.Fatalf("unexpected request path: %s", path)
return nil, nil
}))
attachChatListCmd(t, rt,
map[string]string{"types": "p2p,group"},
map[string]bool{"exclude-muted": true})
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
var parsed struct {
Data struct {
Chats []map[string]interface{} `json:"chats"`
Filter struct {
Applied string `json:"applied"`
FilteredCount int `json:"filtered_count"`
} `json:"filter"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
}
if parsed.Data.Filter.Applied != "exclude_muted" {
t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s",
parsed.Data.Filter.Applied, out)
}
if parsed.Data.Filter.FilteredCount != 1 {
t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s",
parsed.Data.Filter.FilteredCount, out)
}
// The muted p2p row should be gone from chats; only oc_g remains.
if len(parsed.Data.Chats) != 1 {
t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out)
}
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}

View File

@@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -34,6 +34,7 @@ var ImChatMessageList = common.Shortcut{
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
@@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{
dryParams[k] = vs[0]
}
}
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Under bot identity, --user-id is not supported; require --chat-id only.
@@ -111,16 +117,28 @@ var ImChatMessageList = common.Shortcut{
hasMore, nextPageToken := common.PaginationMeta(data)
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop. Each merge_forward in the page would otherwise issue
// its own serial GET inside FormatMessageItem; N merge_forwards turned
// into N × ~1s of stall. Passing nameCache also lets the prefetch
// batch-resolve every sub-item's sender open_id in one contact API
// call, so the per-merge_forward render path doesn't fan out N more
// serial contact requests during the FormatMessageItem loop.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -22,16 +22,22 @@ var ImMessagesMGet = common.Shortcut{
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
return common.NewDryRunAPI().GET(buildMGetURL(ids))
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
@@ -60,15 +66,25 @@ var ImMessagesMGet = common.Shortcut{
rawItems, _ := data["items"].([]interface{})
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop, so N merge_forwards in the input don't serialize
// into N × ~1s of stall inside FormatMessageItem. Passing nameCache
// also pre-resolves every sub-item's sender open_id in one batched
// contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
Command: "+messages-search",
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
Risk: "read",
Scopes: []string{"search:message", "contact:user.basic_profile:readonly"},
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -49,6 +49,7 @@ var ImMessagesSearch = common.Shortcut{
{Name: "page-token", Desc: "page token"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
@@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{
} else {
d = d.Desc("Step 1: search messages")
}
return d.
d = d.
POST("/open-apis/im/v1/messages/search").
Params(dryParams).
Body(req.body).
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildMessagesSearchRequest(runtime)
@@ -153,13 +159,19 @@ var ImMessagesSearch = common.Shortcut{
// ── Step 4: Format message content + attach chat context ──
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop, so N merge_forwards in the search hits don't
// serialize into N × ~1s of stall inside FormatMessageItem. Passing
// nameCache also pre-resolves every sub-item's sender open_id in one
// batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, msgItems, nameCache)
enriched := make([]map[string]interface{}, 0, len(msgItems))
for _, item := range msgItems {
m, _ := item.(map[string]interface{})
chatId, _ := m["chat_id"].(string)
// Reuse unified content converter
msg := convertlib.FormatMessageItem(m, runtime, nameCache)
msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)
if chatId != "" {
msg["chat_id"] = chatId
}
@@ -184,6 +196,9 @@ var ImMessagesSearch = common.Shortcut{
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
convertlib.AttachSenderNames(enriched, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, enriched)
}
outData := map[string]interface{}{
"messages": enriched,

View File

@@ -81,10 +81,14 @@ var ImMessagesSend = common.Shortcut{
if desc != "" {
d.Desc(desc)
}
return d.
d.
POST("/open-apis/im/v1/messages").
Params(map[string]interface{}{"receive_id_type": receiveIdType}).
Body(body)
if chatFlag != "" {
d.Desc("NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with `Bot/User can NOT be out of the chat`.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatFlag := runtime.Str("chat-id")

View File

@@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -33,6 +33,7 @@ var ImThreadsMessagesList = common.Shortcut{
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
@@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{
params["page_token"] = pageToken
}
return d.
d = d.
GET("/open-apis/im/v1/messages").
Params(params).
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId := runtime.Str("thread")
@@ -115,15 +121,25 @@ var ImThreadsMessagesList = common.Shortcut{
hasMore, nextPageToken := common.PaginationMeta(data)
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop. Thread replies that are themselves merge_forward
// messages would otherwise issue serial GETs inside FormatMessageItem.
// Passing nameCache also pre-resolves every sub-item's sender open_id
// in one batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"thread_id": threadId,

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"encoding/binary"
"testing"
)
// build64BitBox builds an ISO-BMFF box using the 64-bit "largesize" form: the
// 32-bit size field is set to 1 and an 8-byte largesize follows the 4-byte box
// type. largesize is the total box length including the 16-byte header.
func build64BitBox(boxType string, largesize uint64, payload []byte) []byte {
box := make([]byte, 16+len(payload))
binary.BigEndian.PutUint32(box[0:4], 1) // size == 1 → 64-bit largesize follows
copy(box[4:8], boxType)
binary.BigEndian.PutUint64(box[8:16], largesize)
copy(box[16:], payload)
return box
}
// build32BitBox builds an ISO-BMFF box using the ordinary 32-bit size form.
func build32BitBox(boxType string, payload []byte) []byte {
box := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(box[0:4], uint32(len(box)))
copy(box[4:8], boxType)
copy(box[8:], payload)
return box
}
// TestMP4BoxLargeSizeOverflowNoPanic guards the 64-bit box-size branch against
// CWE-190 integer overflow. A largesize whose high bit is set converts to a
// negative offset; without a bounds guard that offset indexes the input slice
// out of range and panics, crashing the CLI on a crafted/corrupt MP4 (the
// in-memory walkers run on URL-sourced media that the caller does not control).
// The walkers' contract is best-effort: malformed input must return 0, not panic.
func TestMP4BoxLargeSizeOverflowNoPanic(t *testing.T) {
// A single top-level box in the 64-bit form with largesize = 2^64-1.
data := build64BitBox("ftyp", 0xFFFFFFFFFFFFFFFF, nil)
if got := readMp4DurationBytes(data); got != 0 {
t.Errorf("readMp4DurationBytes(overflow largesize) = %d, want 0", got)
}
if got := parseMp4Duration(data); got != 0 {
t.Errorf("parseMp4Duration(overflow largesize) = %d, want 0", got)
}
if start, end := findMP4Box(data, 0, len(data), "ftyp"); start != -1 || end != -1 {
t.Errorf("findMP4Box(overflow largesize) = (%d, %d), want (-1, -1)", start, end)
}
}
// TestMP4Box64BitSizeAtNonZeroOffset locks in correct handling of a 64-bit box
// that does not start at offset 0. boxEnd must be offset+largesize (as the
// 32-bit branch already does with offset+size); dropping the offset truncates
// the box and the duration is silently lost.
func TestMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
mvhd := buildMvhdBox(0, 1000, 5000) // timescale=1000, duration=5000 → 5000ms
// moov carried as a 64-bit box: largesize = 16-byte header + mvhd payload.
moov := build64BitBox("moov", uint64(16+len(mvhd)), mvhd)
// Precede moov with a 32-bit ftyp box so it sits at a non-zero offset —
// that is where the missing "offset +" surfaces.
data := append(build32BitBox("ftyp", []byte("isom")), moov...)
if got := readMp4DurationBytes(data); got != 5000 {
t.Errorf("readMp4DurationBytes(64-bit moov at offset>0) = %d, want 5000", got)
}
}
// TestFindMP4Box64BitSizeAtNonZeroOffset is the findMP4Box-level analogue: a
// 64-bit box preceding the target must advance the cursor by offset+largesize
// so the following box is located at the right position.
func TestFindMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
free := build64BitBox("free", 24, make([]byte, 8)) // 16-byte header + 8 bytes
target := build32BitBox("mvhd", []byte("payload!"))
data := append(free, target...)
start, end := findMP4Box(data, 0, len(data), "mvhd")
if start < 0 {
t.Fatalf("findMP4Box did not find mvhd after a 64-bit box (start=%d)", start)
}
if got := string(data[start:end]); got != "payload!" {
t.Errorf("findMP4Box returned %q, want %q", got, "payload!")
}
}

109
shortcuts/mail/body_file.go Normal file
View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// bodyFileFlag is the shared `--body-file` flag declaration reused by every
// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward).
// All six shortcuts honour the same mutual-exclusion contract with `--body`
// and the cwd-subtree path safety rule. The flag is intentionally NOT
// shared with `+lint-html` because that command's description differs
// ("HTML to lint" vs "email body") in a way that is more readable when
// authored per-shortcut. `+draft-edit` does not expose `--body-file` either
// — its body ops flow through `--patch-file` JSON whose `value` field is
// the natural file-based entry point for large bodies.
var bodyFileFlag = common.Flag{
Name: "body-file",
Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.",
Input: []string{common.File},
}
// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose
// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a
// bit more headroom here (32 MB) so a body close to the limit still loads
// before the downstream check fires with a clearer error message. The cap
// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte
// file.
const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
// validateBodyFileMutex enforces the `--body` / `--body-file` mutual
// exclusion + cwd-subtree path safety. Compose shortcuts call this in
// their Validate phase so AI / users see a clear error before any work
// runs. Pass the shortcut's RuntimeContext-resolved flag values directly:
// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the
// trimmed `--body-file` value, and `validatePath` is the
// runtime.ValidatePath bound function used to enforce the relative-path
// rule (cwd-subtree only; no absolute / `..` traversal).
//
// Returns an ErrValidation error when either invariant is violated, nil
// otherwise. The "exactly one of {--body, --body-file}" check is
// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with
// no explicit body) and is therefore left to the caller.
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
if !bodyEmpty && bodyFile != "" {
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
}
if bodyFile != "" {
if err := validatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)
}
}
return nil
}
// resolveBodyFromFlags returns the body content from --body or --body-file.
// Validate has already enforced mutual exclusion via validateBodyFileMutex,
// so exactly one is set (or neither when a template / parent message
// supplies the body). Returns ("", nil) when neither flag is set so
// downstream code can decide whether the empty body is allowed.
func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
if body := runtime.Str("body"); strings.TrimSpace(body) != "" {
return body, nil
}
path := strings.TrimSpace(runtime.Str("body-file"))
if path == "" {
return "", nil
}
return readBodyFile(runtime.FileIO(), path)
}
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
if !hasTemplate && strings.TrimSpace(body) == "" {
return output.ErrValidation(message)
}
return nil
}
// readBodyFile loads --body-file content with a size cap. Returns an
// ErrValidation error if the file exceeds maxBodyFileSize or any IO error
// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any
// over-cap byte is observable without reading the whole file.
//
// Callers MUST have run runtime.ValidatePath(path) on `path` first — the
// helper only opens the file via the supplied FileIO and does not repeat
// the cwd-subtree safety check.
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
f, err := fio.Open(path)
if err != nil {
return "", output.ErrValidation("open --body-file %s: %v", path, err)
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
if err != nil {
return "", output.ErrValidation("read --body-file %s: %v", path, err)
}
if len(buf) > maxBodyFileSize {
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
}
return string(buf), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,920 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package lint
import (
"strings"
"testing"
)
// =====================================================================
// Tier 1 — pass-through tags / attrs / styles (tag classification row "通过").
// =====================================================================
// TestRun_AllowedTagsPassThrough verifies that the canonical Feishu-native
// tag set passes through without findings (tag classification row "通过").
func TestRun_AllowedTagsPassThrough(t *testing.T) {
cases := []struct {
name string
html string
}{
{"plain paragraph", `<p>hello world</p>`},
{"div with span", `<div><span>nested</span></div>`},
{"unordered list", `<ul><li>a</li><li>b</li></ul>`},
{"ordered list", `<ol><li>x</li></ol>`},
{"table", `<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>`},
{"headings", `<h1>t</h1><h2>t</h2><h3>t</h3><h4>t</h4><h5>t</h5><h6>t</h6>`},
{"emphasis", `<b>b</b><i>i</i><em>e</em><strong>s</strong><u>u</u><s>k</s>`},
{"sub sup", `<sub>s</sub><sup>p</sup>`},
{"hr br", `<p>x<br>y</p><hr>`},
{"blockquote", `<blockquote>q</blockquote>`},
{"code pre", `<pre><code>x = 1</code></pre>`},
{"safe href", `<a href="https://example.com">link</a>`},
{"mailto href", `<a href="mailto:a@b.c">m</a>`},
{"cid img", `<img src="cid:abc123">`},
{"data:image png", `<img src="data:image/png;base64,iVBOR" alt="x">`},
{"feishu native quote class",
`<div class="adit-html-block adit-html-block--collapsed"><div>x</div></div>`},
}
// Feishu-native autofix rules apply to <p>/<ul>/<ol>/<li>/<blockquote>/<a>
// — those are not "violations" so must not be flagged as errors. We
// allow STYLE_*_NATIVE_INLINE_APPLIED + STYLE_PARA_WRAPPER_REWRITTEN
// findings here but reject any other rule.
feishuNativeRules := map[string]bool{
RuleStyleListNative: true,
RuleStyleListItemNative: true,
RuleStyleBlockquoteNative: true,
RuleStyleLinkNative: true,
RuleStyleParaWrapper: true,
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("expected no errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
for _, f := range rep.Applied {
if !feishuNativeRules[f.RuleID] {
t.Errorf("unexpected non-Feishu-native warning: %+v", f)
}
}
})
}
}
// TestRun_AllowedStylePropertiesPassThrough verifies all allowed style
// properties survive a round-trip without dropping.
func TestRun_AllowedStylePropertiesPassThrough(t *testing.T) {
allowed := []string{
"color:rgb(31,35,41)",
"background-color:rgb(245,246,247)",
"font-size:14px",
"font-weight:bold",
"font-style:italic",
"text-align:center",
"text-decoration:underline",
"line-height:1.6",
"padding:8px",
"margin:12px",
"border:1px solid #ccc",
"border-top:1px solid red",
"border-bottom:2px solid blue",
"border-left:1px",
"border-right:1px",
"width:100%",
"height:auto",
"display:block",
"text-indent:2em",
}
for _, prop := range allowed {
t.Run(prop, func(t *testing.T) {
html := `<p style="` + prop + `">x</p>`
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("property %q unexpectedly dropped: %+v", prop, f)
}
}
})
}
}
// =====================================================================
// Tier 2 — warning + autofix tags (tag classification row "警告 + 自动修复").
// =====================================================================
// TestRun_FontTagAutofixedToSpan verifies <font color="..."> rewrites to
// <span style="color:..."> with AutoFix=true.
func TestRun_FontTagAutofixedToSpan(t *testing.T) {
// Use <div> wrapper to avoid the Feishu-native paragraph autofix
// firing alongside the <font> rewrite.
rep := Run(`<div><font color="red">x</font></div>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d: %+v", len(rep.Applied), rep.Applied)
}
got := rep.Applied[0]
if got.RuleID != RuleTagFontToSpan {
t.Errorf("rule = %s, want %s", got.RuleID, RuleTagFontToSpan)
}
if got.Severity != SeverityWarning {
t.Errorf("severity = %s, want warning", got.Severity)
}
if !strings.Contains(rep.CleanedHTML, "<span") || strings.Contains(rep.CleanedHTML, "<font") {
t.Errorf("expected <font>→<span> rewrite, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color preserved as inline style, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagSizeMappedToPx checks legacy <font size="N"> → font-size:Npx.
func TestRun_FontTagSizeMappedToPx(t *testing.T) {
rep := Run(`<font size="3">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-size:16px") {
t.Errorf("expected size=3 → 16px, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagAutofixedToDiv verifies <center> → <div text-align:center>.
func TestRun_CenterTagAutofixedToDiv(t *testing.T) {
rep := Run(`<center>x</center>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d", len(rep.Applied))
}
if rep.Applied[0].RuleID != RuleTagCenterToDiv {
t.Errorf("rule = %s, want %s", rep.Applied[0].RuleID, RuleTagCenterToDiv)
}
if !strings.Contains(rep.CleanedHTML, "<div") || !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected <center>→<div text-align:center>, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<center") {
t.Errorf("<center> should have been replaced, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeBlinkCollapseToSpan verifies <marquee>/<blink> → <span>.
func TestRun_MarqueeBlinkCollapseToSpan(t *testing.T) {
for _, tag := range []string{"marquee", "blink"} {
rep := Run("<"+tag+">x</"+tag+">", Options{})
if len(rep.Applied) != 1 {
t.Errorf("[%s] expected 1 warning, got %d", tag, len(rep.Applied))
continue
}
if !strings.Contains(rep.CleanedHTML, "<span") {
t.Errorf("[%s] expected <span> wrapper, cleaned=%q", tag, rep.CleanedHTML)
}
}
}
// =====================================================================
// Tier 3 — error / delete tags (tag classification row "错误(删除)").
// =====================================================================
// TestRun_ScriptTagBlocked checks that <script> is removed unconditionally.
func TestRun_ScriptTagBlocked(t *testing.T) {
rep := Run(`<p>safe</p><script>alert(1)</script><p>after</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleTagScriptBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked)
}
if strings.Contains(rep.CleanedHTML, "<script") || strings.Contains(rep.CleanedHTML, "alert(1)") {
t.Errorf("<script> content should be deleted, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedTagsRemoved iterates all error-tier tags.
func TestRun_BlockedTagsRemoved(t *testing.T) {
cases := map[string]string{
`<iframe src="x"></iframe>`: RuleTagIframeBlocked,
`<object data="x"></object>`: RuleTagObjectBlocked,
`<embed src="x">`: RuleTagEmbedBlocked,
`<form action="x"><input></form>`: RuleTagFormBlocked,
`<link rel="stylesheet" href="x.css">`: RuleTagLinkBlocked,
`<meta http-equiv="refresh" content="0">`: RuleTagMetaBlocked,
`<base href="https://evil.com">`: RuleTagBaseBlocked,
}
for input, wantRule := range cases {
t.Run(input[:min(len(input), 30)], func(t *testing.T) {
rep := Run(input, Options{})
found := false
for _, f := range rep.Blocked {
if f.RuleID == wantRule {
found = true
break
}
}
if !found {
t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked)
}
})
}
}
// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are
// stripped — they are an event-handler injection vector.
func TestRun_EventHandlerAttrBlocked(t *testing.T) {
rep := Run(`<p onclick="alert(1)" id="ok">x</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked)
}
if strings.Contains(rep.CleanedHTML, "onclick") {
t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, `id="ok"`) {
t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors.
func TestRun_OnErrorAttrBlocked(t *testing.T) {
rep := Run(`<img src="cid:x" onerror="alert(1)">`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" {
hasErr = true
}
}
if !hasErr {
t.Errorf("onerror should fire, got %+v", rep.Blocked)
}
}
// =====================================================================
// URL scheme allow-list.
// =====================================================================
// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped.
func TestRun_JavaScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="javascript:alert(1)">click</a>`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
hasErr = true
}
}
if !hasErr {
t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked)
}
if strings.Contains(rep.CleanedHTML, "javascript:") {
t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_VBScriptURLBlocked verifies vbscript: is rejected.
func TestRun_VBScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="vbscript:msgbox 1">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected vbscript: to be blocked, got 0 findings")
}
}
// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected
// (only data:image/* is allowed).
func TestRun_DataNonImageURLBlocked(t *testing.T) {
rep := Run(`<img src="data:text/html,<script>1</script>">`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected data:text/html to be blocked")
}
}
// TestRun_DataImageAllowed verifies data:image/png passes.
func TestRun_DataImageAllowed(t *testing.T) {
rep := Run(`<img src="data:image/png;base64,iVBORw0KGg=">`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
t.Errorf("data:image/* should pass, got %+v", f)
}
}
}
// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass.
func TestRun_RelativeURLAllowed(t *testing.T) {
rep := Run(`<img src="./local.png"><a href="/path">x</a>`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked {
t.Errorf("relative URL should pass, got %+v", f)
}
}
}
// =====================================================================
// Style property allow-list.
// =====================================================================
// TestRun_StylePropertyDropped verifies non-allow-list properties drop.
func TestRun_StylePropertyDropped(t *testing.T) {
rep := Run(`<p style="color:red; position:absolute; z-index:99">x</p>`, Options{})
dropped := []string{}
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
dropped = append(dropped, f.TagOrAttr)
}
}
if !sliceContains(dropped, "style.position") {
t.Errorf("expected position to be dropped, got %v", dropped)
}
if !sliceContains(dropped, "style.z-index") {
t.Errorf("expected z-index to be dropped, got %v", dropped)
}
if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") {
t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule.
func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
rep := Run(`<p style="border-top:1px; border-bottom-color:red; border-radius:4px">x</p>`, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("border-* should pass, got %+v", f)
}
}
}
// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent
// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner
// <ul> (mail-editor's own native nested-list shape), the Feishu-list autofix
// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps
// is supposed to skip props the user already declared, but earlier
// hasInlineStyleProp was only matching longhand `margin-left:` literally
// and missed the shorthand form, causing 24px indents to be reset to 0.
func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) {
in := `<ul style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;list-style-type:circle;font-size:14px" dir="auto"><span style="font-family:inherit"><span style="color:rgb(0,0,0)">indented</span></span></li></ul>`
rep := Run(in, Options{})
cleaned := rep.CleanedHTML
// Extract just the <ul ...> opening tag's style attr (li has its own
// independent margin-left:0 longhand which is correct — list indent
// belongs on the container, not the item).
ulOpen := cleaned
if i := strings.Index(ulOpen, ">"); i >= 0 {
ulOpen = ulOpen[:i]
}
if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") {
t.Errorf("shorthand margin with 24px left should survive on <ul>, ulOpen=%q", ulOpen)
}
// The bug signature: extra `margin-left:` appended after the shorthand
// on the <ul> element itself (CSS rule says the later one wins, so any
// margin-left:0 after the shorthand resets the indent to 0).
if strings.Contains(ulOpen, "margin-left") {
t.Errorf("autofix must not append margin-left longhand onto <ul> when shorthand already declares it, ulOpen=%q", ulOpen)
}
}
// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native
// autofix does not override a user-authored border shorthand by appending
// border-left. CSS applies the later longhand over the earlier shorthand, so
// adding border-left here would replace the user's left border.
func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) {
rep := Run(`<blockquote style="border:1px solid red">quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `border:1px solid red`) {
t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `border-left:`) {
t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `color:rgb(100,106,115)`) {
t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned)
}
}
func TestRun_BlockquoteNativeContentWrapper(t *testing.T) {
rep := Run(`<blockquote>quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
for _, want := range []string{
`class="lark-mail-doc-quote"`,
`border-left:2px solid rgb(187,191,196)`,
`<div dir="auto" style="font-size:14px;padding-left:12px">quoted</div>`,
} {
if !strings.Contains(cleaned, want) {
t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned)
}
}
}
func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) {
in := `<blockquote class="lark-mail-doc-quote" style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:14px;padding-left:12px">quoted</div></blockquote>`
rep := Run(in, Options{})
if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 {
t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML)
}
}
func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) {
rep := Run(`<p style="font-size:20px" dir="rtl">hello</p>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) {
t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `<div dir="rtl">hello</div>`) {
t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `font-size:14px`) {
t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `dir="auto"`) {
t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned)
}
}
// =====================================================================
// CleanedHTML output / contract guarantees.
// =====================================================================
// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty
// slices when nothing is found (the JSON envelope contract requires `[]`,
// not `null`).
func TestRun_EmptyArraysAlwaysPresent(t *testing.T) {
// Use <div> instead of <p> to avoid the Feishu-native paragraph
// rewrite autofix, which would surface a finding even on otherwise
// clean input.
rep := Run(`<div>nothing here</div>`, Options{})
if rep.Applied == nil || rep.Blocked == nil {
t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked))
}
}
// TestEmptyReport_HasContractFields covers the helper used by compose 5's
// plain-text branch.
func TestEmptyReport_HasContractFields(t *testing.T) {
rep := EmptyReport(`plain text`)
if rep.Applied == nil {
t.Error("Applied must be non-nil")
}
if rep.Blocked == nil {
t.Error("Blocked must be non-nil")
}
if rep.CleanedHTML != "plain text" {
t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text")
}
}
// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through
// the parser doesn't accidentally lose user content.
func TestRun_CleanedHTMLPreservesStructure(t *testing.T) {
html := `<div style="line-height:1.6"><h3>title</h3><p>body <b>bold</b> end</p><ul><li>a</li><li>b</li></ul></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Fatalf("unexpected blocked: %+v", rep.Blocked)
}
// Feishu-native autofix expected to fire on <p>, <ul>, <li> — content
// must still survive untouched even though structure is augmented.
for _, want := range []string{"line-height:1.6", "<h3>", "title", "<b>", "bold", "<ul", "<li", "</ul>"} {
if !strings.Contains(rep.CleanedHTML, want) {
t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML)
}
}
}
// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input.
func TestRun_EmptyInput(t *testing.T) {
rep := Run("", Options{})
if rep.CleanedHTML != "" {
t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("empty input must produce empty findings")
}
}
// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings.
func TestRun_HasErrorFindingsFlag(t *testing.T) {
rep := Run(`<script>x</script>`, Options{})
if !rep.HasErrorFindings {
t.Error("expected HasErrorFindings=true")
}
clean := Run(`<p>safe</p>`, Options{})
if clean.HasErrorFindings {
t.Error("expected HasErrorFindings=false on clean HTML")
}
}
// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings.
func TestRun_HasWarningFindingsFlag(t *testing.T) {
rep := Run(`<font color="red">x</font>`, Options{})
if !rep.HasWarningFindings {
t.Error("expected HasWarningFindings=true")
}
}
// =====================================================================
// Excerpt cap.
// =====================================================================
// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap.
func TestTruncateExcerpt_RespectsCap(t *testing.T) {
long := strings.Repeat("x", MaxExcerptBytes+50)
got := truncateExcerpt(long)
if len(got) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes)
}
if !strings.HasSuffix(got, " ...") {
t.Errorf("expected truncation suffix, got %q", got[len(got)-10:])
}
}
// TestRun_ExcerptCappedForLargeOffender verifies large blocked content
// produces a short excerpt (envelope size protection).
func TestRun_ExcerptCappedForLargeOffender(t *testing.T) {
bigAttr := strings.Repeat("a", MaxExcerptBytes*2)
rep := Run(`<a href="javascript:`+bigAttr+`">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Fatal("expected blocked finding")
}
for _, f := range rep.Blocked {
if len(f.Excerpt) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes)
}
}
}
// =====================================================================
// Helpers.
// =====================================================================
func sliceContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// =====================================================================
// Additional coverage for edge cases and exhaustive value mapping.
// =====================================================================
// TestMapFontSize_ExhaustiveSpan covers every <font size="N"> mapping
// + invalid values fall through to "" so the property is dropped.
func TestMapFontSize_ExhaustiveSpan(t *testing.T) {
cases := map[string]string{
"1": "10px",
"2": "13px",
"3": "16px",
"4": "18px",
"5": "24px",
"6": "32px",
"7": "48px",
"": "",
"8": "",
"abc": "",
"3.5": "",
" 3 ": "16px",
}
for raw, want := range cases {
got := mapFontSize(raw)
if got != want {
t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want)
}
}
}
// TestRun_FontTagWithFaceMappedToFontFamily ensures <font face="..."> →
// font-family inline style.
func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) {
rep := Run(`<font face="Arial">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-family:Arial") {
t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an
// existing style attribute on the same element.
func TestRun_FontTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<font color="red" style="line-height:1.6">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagWithExistingStyleMerged ensures <center>'s style merge.
func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<center style="line-height:1.6">x</center>`, Options{})
if !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id.
func TestRun_MarqueeRetainsClassAndID(t *testing.T) {
rep := Run(`<marquee class="cls" id="x" direction="left">y</marquee>`, Options{})
if !strings.Contains(rep.CleanedHTML, `class="cls"`) {
t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, `direction`) {
t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a
// blocked (error) finding and the attribute is dropped.
func TestRun_UnknownSchemeBlocked(t *testing.T) {
rep := Run(`<a href="webcal://x">x</a>`, Options{})
gotBlocked := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrUnsafeSchemeBlocked {
gotBlocked = true
}
}
if !gotBlocked {
t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied)
}
if strings.Contains(rep.CleanedHTML, "webcal:") {
t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..."
// is still caught after control-byte stripping in classifyURLValue.
func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) {
rep := Run("<a href=\"java\tscript:alert(1)\">x</a>", Options{})
gotErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
gotErr = true
}
}
if !gotErr {
t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked)
}
}
// TestRun_FileSchemeBlocked verifies file: URLs are rejected.
func TestRun_FileSchemeBlocked(t *testing.T) {
rep := Run(`<a href="file:///etc/passwd">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Error("expected file: to be blocked")
}
}
// TestRun_StyleMalformedDeclarationDropped verifies a property without a
// colon delimiter is treated as malformed and dropped.
func TestRun_StyleMalformedDeclarationDropped(t *testing.T) {
rep := Run(`<p style="color:red; malformed; line-height:1.6">x</p>`, Options{})
gotMalformed := false
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" {
gotMalformed = true
}
}
if !gotMalformed {
t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied)
}
if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style
// attribute is removed entirely when every property is invalid.
func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix, which adds
// a fresh style attribute on the rewritten outer wrapper.
rep := Run(`<div style="position:absolute; z-index:99">x</div>`, Options{})
if strings.Contains(rep.CleanedHTML, "style=") {
t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes.
func TestRun_StyleEmptyValuePassThrough(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix.
rep := Run(`<div style="">x</div>`, Options{})
if len(rep.Applied) != 0 {
t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied)
}
}
// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a
// non-empty hint (consumer contract).
func TestRun_HintsForAllBlockedTags(t *testing.T) {
cases := []string{
`<script>x</script>`, `<iframe src="x"></iframe>`,
`<object data="x"></object>`, `<embed src="x">`, `<form><input></form>`,
`<select></select>`, `<button>x</button>`, `<link href="x">`,
`<meta name="x">`, `<base href="x">`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Blocked {
if f.Hint == "" {
t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint.
func TestRun_HintsForAllWarnTags(t *testing.T) {
cases := []string{
`<font>x</font>`, `<center>x</center>`,
`<marquee>x</marquee>`, `<blink>x</blink>`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.Hint == "" {
t.Errorf("warn rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestClassifyTag_Coverage exercises classifyTag with every category.
func TestClassifyTag_Coverage(t *testing.T) {
if k, _ := classifyTag("p"); k != "allow" {
t.Errorf("p classified as %q", k)
}
if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked {
t.Errorf("script classified as %q/%q", k, id)
}
if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan {
t.Errorf("font classified as %q/%q", k, id)
}
// Niche tag passes silently (e.g. <details>).
if k, _ := classifyTag("details"); k != "allow" {
t.Errorf("niche tag <details> should pass through, got %q", k)
}
// Case-insensitive.
if k, _ := classifyTag("SCRIPT"); k != "error" {
t.Errorf("SCRIPT (uppercase) should still classify as error")
}
}
// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only,
// no-scheme variants.
func TestClassifyURLValue_CoverageEdges(t *testing.T) {
cases := map[string]string{
"": "ok",
" ": "ok",
"https://x": "ok",
"https://x/path?q=1": "ok",
"#fragment": "ok",
"/relative": "ok",
"javascript:alert(1)": "error",
"vbscript:msgbox 1": "error",
"data:image/png;base64,XYZ": "ok",
"data:text/html,<script>": "error",
"webcal://x": "warn",
}
for raw, want := range cases {
got, _ := classifyURLValue(raw)
if got != want {
t.Errorf("classifyURLValue(%q) = %q, want %q", raw, got, want)
}
}
}
// TestClassifyStyleProperty_Coverage covers prefixes & explicit set.
func TestClassifyStyleProperty_Coverage(t *testing.T) {
cases := map[string]bool{
"color": true,
"BACKGROUND-COLOR": true, // case-insensitive
"border-top": true,
"padding-left": true,
"margin-bottom": true,
"position": false,
"z-index": false,
"": false,
" ": false,
}
for prop, want := range cases {
got := classifyStyleProperty(prop)
if got != want {
t.Errorf("classifyStyleProperty(%q) = %v, want %v", prop, got, want)
}
}
}
// TestIsEventHandlerAttr_Coverage covers the on*-detection rule.
func TestIsEventHandlerAttr_Coverage(t *testing.T) {
cases := map[string]bool{
"onclick": true,
"onmouseover": true,
"OnLoad": true, // case-insensitive
"on0": true,
"on": false, // need at least one char after "on"
"onerror": true,
"onsubmit": true,
"once": true, // would match unfortunately because "once" starts with "on" + 'c'
"id": false,
"href": false,
"data-on": false,
}
for k, want := range cases {
got := isEventHandlerAttr(k)
if got != want {
t.Errorf("isEventHandlerAttr(%q) = %v, want %v", k, got, want)
}
}
}
// TestRun_ParseFailureFallsBackGracefully verifies extreme malformed input
// short-circuits to EmptyReport.
func TestRun_PlainTextInputProducesNoFindings(t *testing.T) {
rep := Run("just a plain string with no markup", Options{})
if len(rep.Blocked) != 0 || len(rep.Applied) != 0 {
t.Errorf("plain text should produce no findings, got %+v %+v", rep.Blocked, rep.Applied)
}
}
// TestRun_MultipleErrorsAccumulate ensures multiple offenders all surface.
func TestRun_MultipleErrorsAccumulate(t *testing.T) {
html := `<script>1</script><iframe></iframe><a href="javascript:0">x</a>` +
`<form></form><p onclick="">y</p>`
rep := Run(html, Options{})
if len(rep.Blocked) < 4 {
t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
}
// TestRun_NestedStructurePreserved verifies deep nesting passes through.
func TestRun_NestedStructurePreserved(t *testing.T) {
html := `<div><div><div><p><span><b>deep</b></span></p></div></div></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked)
}
if !strings.Contains(rep.CleanedHTML, "deep") {
t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a
// blocked tag inside an allowed parent leaves the parent intact.
func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) {
html := `<div>before<script>1</script>after</div>`
rep := Run(html, Options{})
if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<script") {
t.Errorf("script should be removed, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_ListDirectChildNonLIWrapped verifies that a <ul><ul> nested
// directly without an <li> wrapper triggers LIST_DIRECT_CHILD_NON_LI and
// the inner <ul> ends up wrapped in a synthetic <li>. Same for <ol><ol>.
func TestRun_ListDirectChildNonLIWrapped(t *testing.T) {
cases := []struct {
name string
html string
}{
{"ul wraps ul", `<ul><ul><li>x</li></ul></ul>`},
{"ol wraps ol", `<ol><ol><li>x</li></ol></ol>`},
{"ul wraps div", `<ul><div>orphan</div><li>real</li></ul>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
gotRule := false
for _, f := range rep.Applied {
if f.RuleID == RuleListDirectChildNonLI {
gotRule = true
break
}
}
if !gotRule {
t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied)
}
// The cleaned HTML should not have a direct ul>ul or ol>ol or
// ul>div sequence anymore.
if strings.Contains(rep.CleanedHTML, "<ul><ul") ||
strings.Contains(rep.CleanedHTML, "<ol><ol") ||
strings.Contains(rep.CleanedHTML, "<ul><div") {
t.Errorf("expected synthetic <li> wrapper, cleaned=%q", rep.CleanedHTML)
}
})
}
}

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