Compare commits

...
Author SHA1 Message Date
chichuan 4bbd42cc25 Merge pull request #930 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.2
chore(release): seal v1.0.58-beta.2 changelog
2026-08-10 16:04:27 +08:00
chichuan 2cc24de505 chore(release): seal v1.0.58-beta.2 changelog 2026-08-10 15:55:25 +08:00
github-actions[bot] 6575301a3a Merge pull request #926 from DingTalk-Real-AI/ci/reviewed-flag-type-exemption
ci(interface): allow reviewed flag type migrations
2026-08-10 15:22:40 +08:00
chichuan 43121f1d8f test(interface): cover the merge-path bundled-regression branches
mergedFlagContractOtherwiseChanged was only ever exercised on the path where
every condition holds still, because `||` short-circuits: with no reviewed
entry the first operand already decides the outcome and the function is never
called at all. That left its five regression branches uncovered and put
changed-code coverage at 88.0952% against a 100% target.

Add the merge-path counterpart of the checkCompatibility bundled-regression
table, pairing each of shorthand / required / hidden / no-opt / scope with the
reviewed type change and requiring the type failure to reappear. Changed-code
coverage is now 100%.
2026-08-10 15:02:08 +08:00
chichuan cd02fe71e6 ci(interface): allow reviewed flag type migrations
The authoritative interface baseline and command-compatibility gates
rejected every flag type change on a historical command, with no review
channel — even when the new type only moves the same validation from RunE
to flag parsing. Both now consult a precise allowlist.

An entry must match command path, flag name and both type names exactly,
so it is direction-sensitive by construction, and it applies only when
nothing else about the flag moved (shorthand, required, hidden, no-opt,
scope). A bundled regression re-reports the type change.

The first and only entry is "dws minutes permission apply --policy" moving
from string to int (for #912): the old RunE parsed with
strconv.ParseInt(v, 10, 64) and enforced [2,4], the new one lets pflag
parse with base 0 and still enforces [2,4], so the historical set of
successful invocations is a subset of the new one. Base 0 additionally
accepts spellings like "0x3", which widens rather than narrows. Defaults
are excluded from the guard because the migration necessarily changes one.

In the snapshot gate the exemption resolves against the canonical
Command.Path, never the alias-expanded accepted path: an aliased command is
compared once per accepted spelling, so keying on that would let every
alias bypass the table.

The table is duplicated because check-authoritative-interface-baselines.sh
copies the whole scripts/policy/interface-baseline directory into a
worktree checked out at a historical revision and builds it there, so that
copy cannot import a package this branch adds. A guard test fails if the
two copies drift.
2026-08-10 14:26:32 +08:00
github-actions[bot] 93a2071837 Merge pull request #914 from maoqxxmm/codex/align-sheet-skill-docs
docs(sheet): align mono and multi skill references
2026-08-10 14:11:48 +08:00
xiatian 33730337f3 Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs 2026-08-10 12:06:16 +08:00
xiatian 6be12655dc fix(skills): address sheet review feedback 2026-08-10 12:06:07 +08:00
github-actions[bot] cf3bcb380f Merge pull request #897 from Anonymity-0/feat/chat-message-help-id-chain
docs(chat): document post-send message ID chain
2026-08-10 04:01:36 +00:00
Anonymity-0 89e8bd7015 Merge branch 'main' into feat/chat-message-help-id-chain 2026-08-10 11:42:02 +08:00
xiatian 5de36d783a Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs
# Conflicts:
#	CHANGELOG.md
#	skills/mono/references/products/sheet/sheet-dimension-operations.md
#	skills/mono/references/products/sheet/sheet-export.md
#	skills/mono/references/products/sheet/sheet-style-format.md
#	skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md
#	skills/multi/dingtalk-misc/references/sheet/sheet-export.md
2026-08-10 10:21:37 +08:00
github-actions[bot] 2bc4ded969 Merge pull request #883 from Huwenjiao/feat/sheet-sync-a1-a2
feat(sheet): CSV export, style extensions and create-with-data
2026-08-10 10:05:57 +08:00
xiatian 468f9200f6 Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs
# Conflicts:
#	CHANGELOG.md
2026-08-10 09:52:53 +08:00
huwenjiao.hwjandClaude Opus 5 6f61183732 fix(sheet): reject sheet prefixes that are blank after trimming
--ranges validated the position of "!" in the raw string and then returned the
trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range /
clear_range operation carrying sheetId: "". Depending on how the server treats
an empty sheetId, the whole batch_update fails, or — worse — the operation lands
on the default worksheet instead of the one the user named, while the command
reports success.

Both halves must now be non-empty *after* trimming. batch-clear grew the same
hole independently (it duplicated the split inline); it now shares
splitSheetPrefixedRange, so the invariant holds by construction rather than by
being repeated correctly in two places.

batch-set-style --batch had the same gap at the JSON level: it only rejected
sheetId == "", so "   " passed. It now judges the trimmed value but still sends
the raw one — sheetId may be a worksheet *name*, and names may legitimately
carry leading or trailing spaces, so trimming on the user's behalf would target
a different sheet. The --ranges form cannot express such a name anyway, which is
what --batch is for.

TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry
points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the
no-normalisation half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:33:56 +08:00
huwenjiao.hwjandClaude Opus 5 332b74e8ce refactor(sheet): split create-with-data and export-csv onto their own leaves
Both capabilities were added as flags on an existing leaf, and in both cases
the leaf's published interface stopped describing what the command did:

- `sheet create --values/--sheets/--styles` orchestrates create → probe →
  resolve default worksheet → write → read back → optional styles, yet the
  leaf still published `interface_mode: mcp` + `create_workspace_sheet`.
- `sheet export --export-format csv` reads `get_range_as_csv` and never
  invokes `submit_export_job`, yet the leaf published `submit_export_job`.

Each moves to its own command, declaring the interface it actually uses:
`sheet create-with-data` is `composite` with a reviewed reason and no
`interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are
pinned in the interface-disposition contract test.

`sheet create` and `sheet export` are restored byte-for-byte to main, so the
compatibility gates see two `command_added` additions instead of four
locked-field changes. The split also removes a user-visible trap: `--range`
without `--export-format csv` used to be silently discarded and the whole
workbook exported; the cross-format flags no longer exist, pinned by
TestSheetExportAndExportCsvFlagsDoNotLeak.

Drops the 8 now-stale mapping-ledger exclusions that covered the flags on
`sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this
branch's exclusion surface. Skill references and CHANGELOG follow the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:47:50 +08:00
huwenjiao.hwjandClaude Opus 5 10bb2ec205 docs(changelog): record the composite-orchestration contract gap on both leaves
sheet create 带 --values/--sheets/--styles 时会依次执行探活、重命名、写入、回读与
可选样式操作,已不是一次 create_workspace_sheet 直接调用;sheet export
--export-format csv 实际读 get_range_as_csv、完全不碰 submit_export_job。两个叶子
却都仍声明 interface_mode: mcp 加单一 interface_ref,Schema 消费者会误判为单次
RPC,并把编排步骤或另一分支的参数当成该 RPC 的入参。

此前只有 csv 分支记了一条含糊的已知限制、create 侧完全没记。现在两条都写清楚:
不准确的具体表现、影响面(仅审计元数据,不影响执行),以及诚实的声明方式——拆成
独立叶子,或把叶子改为 interface_mode: composite。同时记下后者为何留待后续迁移:
对既有叶子而言 interface_mode 变更在 schema-compatibility 中是无条件失败,
checkToolCompatibility 对该字段没有任何豁免通道,而 interface_ref 的 reviewed
redirect 豁免明确要求 mode 保持 mcp 不变。

注意:这是把契约不准确记录成已知限制,不是修复。评审要求的是结构性修改。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:30:32 +08:00
huwenjiao.hwjandClaude Opus 5 ac0125e468 docs(changelog): record the stricter --length parsing as a behaviour change
--length 的解析由 fmt.Sscanf("%d") 改为 strconv.Atoi,影响 sheet
insert-dimension / delete-dimension / update-dimension 三个既有命令。这是对既有
命令的用户可见行为变更,此前只作为实现细节修掉,CHANGELOG 与 PR 描述都没记:原先
Sscanf 只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作(删除方向不可
回滚);现在整个值必须是合法正整数。原先依赖宽松解析、在传畸形 --length 的脚本
升级后会开始报错,用户需要能在发布说明里找到解释。

CHANGELOG 的 Unreleased ### Changed 补一条(含升级影响与 add-dimension 不受影响
的说明),PR 描述的 Summary 补一张 Behaviour change 表。

同时补一条回归测试钉住该行为:三个命令 × 六种畸形值(2x / 3foo / "1 2" / 0x10 /
abc / 空串)都必须报错且错误信息指明 --length。文档写了的行为需要有测试守着,否则
改回宽松解析不会被发现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:01:14 +08:00
huwenjiao.hwjandClaude Opus 5 f06a24ec2e fix(sheet): write export CSV atomically and reject cross-format flags
CSV 落盘从 os.WriteFile 改为仓库已有的 AtomicWrite:os.WriteFile 会先把
已存在的目标文件截断,写入中途失败(磁盘满、配额、I/O 错误)就把用户的原
文件毁掉了。改为写同目录临时文件再 rename,失败时原文件保持不变、临时文件
被清理。父目录仍先 stat 一次,保持与 xlsx 分支一致的「父目录不存在即报错」
语义,不让 AtomicWrite 的 MkdirAll 把拼错的路径悄悄建成目录。

格式分派后拒绝不属于当前分支的显式参数:--sheet-id / --range /
--value-render-option / --allow-truncated 只有 csv 分支消费,此前落到默认的
xlsx 分支会被静默忽略。自动化漏写 --export-format csv 时,用户要的 --range
被丢掉、导出的却是整篇工作簿,命令仍报成功。现在按 Flags().Changed 检测并
在提交导出任务之前报错,同时列出所有误用的参数。反向不需要检查:xlsx 分支
没有专属参数,node / output / export-format 两条分支共用。

测试覆盖 rename 失败后原文件完好且目录无临时文件残留、四个 csv 专属 flag 在
默认与显式 xlsx 下各自被拒且零远程调用、多个参数同时误用时全部列出、csv 分支
照常接受它们;另加一条登记表与实际绑定 flag 的一致性测试,防止新增 flag 漏登记
(漏登记会重新引入静默忽略,误登记共享 flag 会拒掉合法的 xlsx 调用)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:26:40 +08:00
huwenjiao.hwjandClaude Opus 5 8f135ecde6 fix(sheet): reject unknown and mistyped fields in border edge configs
parseBorderStyles read only style and a string color, so every other key and
any non-string color was silently dropped:
{"top":{"style":"solid","colour":"#f00"}} succeeded and drew a border with no
colour, and color: 123 did the same. That contradicts the unknown-key
rejection this PR applies to --sheets and --styles — a partially applied
style reported as success is harder to notice than an error.

Each edge now accepts only style/color, rejects near-miss spellings with the
canonical key, and fails when style or color is present with the wrong type
or empty. All three entry points (set-style --border-styles-json,
batch-set-style --ranges/--batch, and create --styles border_styles) share
parseBorderStyles, so one fix covers them; tests assert the rejection happens
before any MCP call on every path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:05:18 +08:00
huwenjiao.hwj a2e51f2b86 Merge remote-tracking branch 'upstream/main' into feat/sheet-sync-a1-a2 2026-08-08 14:13:02 +08:00
huwenjiao.hwjandClaude Opus 5 3d6e62fc08 docs(sheet): document the batch style caps and --styles size rules
Sweeping the same class the last review round hit: limits and behaviour this
PR added that only reached the Go long help, not the skill references an
agent actually reads.

- batch-set-style: the 200000-cell cumulative cap across all ranges was
  missing (the 100-range cap and the atomic rollback were already there).
- sheet create --styles: size must be a positive integer (a fraction is
  rejected rather than silently truncated) and the row/column range forms
  reject trailing characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:17:45 +08:00
huwenjiao.hwj c6094e291e Merge remote-tracking branch 'upstream/main' into feat/sheet-sync-a1-a2
# Conflicts:
#	CHANGELOG.md
2026-08-08 00:03:17 +08:00
huwenjiao.hwjandClaude Opus 5 e026c754e0 fix(sheet): require EOF after the --values / --sheets JSON value
json.Decoder.Decode returns after one value, so `--values '[[1]] trailing'`
was accepted as a valid matrix and the document got created anyway. A paste
that ran long, leftover shell concatenation, or two JSON values glued
together would silently drop the tail and still create a document the user
never asked for — and creation cannot be rolled back atomically. Require
EOF after the first value on both flags, following decodeOARequest.

docs(sheet): document the fail-closed CSV export and --allow-truncated

The skill references still claimed an oversized table is truncated with a
warning on stderr, and omitted the flag. The command now aborts before
writing anything when the server reports hasMore, so an agent relying on the
skill would misread the result and had no way to learn how to opt in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:01:47 +08:00
github-actions[bot] 18030f1018 chore: update beta formula for v1.0.58-beta.1 [skip ci] 2026-08-07 15:59:04 +00:00
chichuan 6297b6b0c8 Merge pull request #915 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.1
docs: seal v1.0.58-beta.1 changelog
2026-08-07 23:46:16 +08:00
chichuan e8905a1984 docs: seal v1.0.58-beta.1 changelog 2026-08-07 23:37:52 +08:00
huwenjiao.hwjandClaude Opus 5 a097d57510 fix(sheet): validate the full --sheets contract before creating the document
sheet create promises that every structural check happens before the first
MCP request, but each sheet spec was only checked for object type and name:
columns/data/dtypes/formats/startCell were left to table_put, so a bad type
created and renamed the remote document first and failed at write time,
leaving behind a document the user never successfully asked for. Validate
every provided field against table_put's input contract up front, and reject
the malformed {"sheets":"bad"} wrapper instead of treating it as one spec.

Also fixed while auditing the same flow:
- unknown/misspelled keys are now rejected in both --sheets and --styles.
  The server DTOs are fixed beans, so a stray "datas" was silently dropped
  and the read-back probe landed on the header row: full data loss reported
  as success. Near-miss spellings get the canonical key in the message.
- columns is required (the server requires it), non-blank and trim-unique;
  dtypes/formats keys must resolve to a column, since the server looks them
  up by trimmed name and silently ignores the rest.
- sheetId inside a spec is rejected: the document does not exist yet.
- the read-back probe now honours header:false, mode:append (a fresh sheet
  appends at row 1, the startCell row is ignored) and $-absolute/lowercase
  startCell refs, which the server accepts after uppercasing.
- --values cells must be scalars; a map used to be written as "map[a:1]".
- the 30000-cell and 2000000-char write limits are enforced locally.

Docs: the --styles top level only accepts snake_case (camelCase aliases are
inner-field only), and the read-back probe is not pinned to A1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:37:15 +08:00
huwenjiao.hwj b566afe3e2 docs(changelog): record the csv-branch interface_ref limitation on sheet export
The --export-format csv branch reads get_range_as_csv, but the sheet export leaf
still declares interface_ref: submit_export_job, so discovering the csv
capability through Schema yields the wrong backing interface. Audit metadata
only — interface_ref is not read at runtime and routing is unaffected. Recorded
here so the limitation reaches release notes rather than living only in a code
comment; accurate attribution is tracked as follow-up.
2026-08-07 22:01:49 +08:00
huwenjiao.hwj 7405825294 fix(policy): register the reviewed interface_ref redirect in its canonical form
The allowlist added in the previous commit keyed the reviewed
sheet.range_set_style migration by bare RPC name ("update_range"), but
interface_ref holds the canonicalized JSON that parseTool produces via
canonicalRawJSON. The lookup therefore never matched, the carve-out was
effectively disabled, and the real gate failed with
`schema tool "sheet/sheet.range_set_style" changed interface_ref`.

The existing redirect test did not catch this: it registered the fixture entry
using its own value and then asserted with the same value, so any format would
have passed. That is the same mistake as keying a probe on the author's field
spelling instead of the wire contract.

- The allowlist entry now uses the compact canonical JSON, taken from the gate's
  own output rather than from pretty-printed `dws schema`.
- TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON recomputes every
  registered key through canonicalRawJSON, so a bare name or a pretty-printed
  variant fails locally instead of only in CI.

sheet export keeps its reviewed mcp + submit_export_job declaration. The comment
now records the known trade-off explicitly: --export-format csv is a mutually
exclusive branch that reads get_range_as_csv and never invokes the declared
submit_export_job, so this declaration does not cover the csv branch's backing
interface. Attributing that branch is left out of scope for this change.

Verified against the real gate, not just unit tests: all four Interface
Integrity checks pass (authoritative-interface-integrity,
check-command-compatibility, schema-compatibility, skill-command-integrity).
2026-08-07 21:37:23 +08:00
huwenjiao.hwj 7eb39ad5d6 fix(sheet): narrow the interface_ref carve-out, fail closed on truncated CSV
Two review findings, plus a same-class defect found by sweeping for it.

1. compatibleInterfaceRefRedirect accepted any mcp tool repointing from any
   non-empty interface_ref to any other, as long as no other check for that tool
   failed. Schema shape cannot prove two RPCs share business semantics,
   permissions, error behaviour, or side effects, so that would have let every
   future backend swap bypass the gate it exists to enforce. It is now keyed on
   an explicit reviewedInterfaceRefRedirect allowlist of exact tool + old→new
   pairs, currently holding only the reviewed
   sheet.range_set_style: update_range → set_cell_range migration. Every other
   ref change is reported again.

2. sheet export --export-format csv only printed a stderr warning when
   get_range_as_csv returned hasMore=true, then wrote --output and reported
   success with exit code 0. Automated callers, and anyone not watching stderr,
   would treat an incomplete file as a complete export, and an existing target
   file was overwritten with truncated data. Truncation now fails before the
   write (leaving any existing file untouched) unless --allow-truncated is
   passed; with the opt-in the success line states the data is incomplete.
   --allow-truncated is registered in the reviewed mapping ledger as a local
   policy input.

3. Swept for the same classes and found sheet_dimension.go repeating the
   fmt.Sscanf("%d") prefix-parse hole in three places: insert_dimension,
   delete_dimension, and update_dimension all accepted --length "3x" as 3, so a
   malformed value silently operated on the wrong row/column count — the delete
   direction is not rollbackable. All three now use strconv.Atoi. (Checked and
   cleared: sheet csv-get also surfaces hasMore, but it has no --output and only
   returns the flag in its JSON payload, so it is not the same fail-open shape.)

Tests: allowlist rejection cases (unreviewed target ref, and the same pair on a
tool absent from the allowlist, asserted outside the table so the registration
survives until checkCompatibility runs); truncation fail-closed with a
pre-existing output file asserted byte-for-byte unchanged; --allow-truncated
write-through; and an untruncated read needing no opt-in. Changed-code coverage
stays at 100% (939 statements).
2026-08-07 21:05:47 +08:00
huwenjiao.hwj eb73b944a1 fix(sheet): reject row/column ranges with trailing characters up front
parseRowColRange gates --styles row_sizes/col_sizes before the document is
created. The row branch parsed with fmt.Sscanf(a, "%d", &r1), which consumes
only the leading digits and does not require the whole token, so "1x:3" was
silently accepted as row 1 and "2foo" as row 2. Such input passed pre-flight,
then update_dimension was sent to the wrong row after the document and data had
already been created — an unrollbackable wrong edit, the opposite of the gate's
purpose. The row branch now parses with strconv.Atoi, which requires the entire
token to be a valid integer.

The column branch had the same class of hole via a different path: parseA1Cell
appends "1" to the column token, so "A5" became "A51" and was accepted as
column A. A new isAllLetters pre-check requires the column token to be non-empty
and letters-only before parsing; genuine multi-letter columns like "AX" still
pass. With that guarantee the subsequent parseA1Cell can no longer fail, so its
now-dead error branch is removed.

Adds trailing-character rejection cases to TestParseRowColRange: "1x:3",
"2foo", "1 2:3" (rows), "A5:C", "A1", ":C" (columns), plus "AX:C" to confirm
multi-letter columns remain valid. Changed-code coverage stays at 100%.
2026-08-07 20:26:12 +08:00
huwenjiao.hwj ecaa375be8 fix(sheet): validate merge range up front, fix startCell key, correct CHANGELOG
Three review P1s.

1. CHANGELOG no longer asserts a breaking Schema change this PR does not ship.
   sheet export / sheet create deliver main's mcp + interface_ref and
   schema-compatibility reports ok (0 changed fields), so the "declared as
   composite (breaking)" entry was false and is removed; the set-style entry
   drops the "breaking" framing (accepted as compatible by the reviewed
   mapping-exclusion carve-out); the duplicate ### Changed heading is merged.

2. firstNonEmptySheetSpecCell reads the start cell via
   pickStr(spec, "startCell", "start_cell"). Sheet specs are forwarded verbatim
   to table_put, whose wire fields are camelCase in this repo. The prior
   snake_case-only read meant a user passing the real startCell would have data
   written at the offset while the probe read A1 — a false "写入未生效" on a
   successful write. camelCase preferred, snake_case kept for tolerance.

3. planStyleOps now parses cell_merges range with parseA1Range, matching the
   cell_styles branch. planStyleOps is dry-run once before create_workspace_sheet
   as the up-front structural gate, so an invalid range like "not-a-range" is now
   rejected before any RPC instead of failing only at the final merge_cells call
   and leaving an unrollbackable partially-completed document. merge_cells' range
   contract is A1:B3-style, which parseA1Range covers (and it strips a Sheet1!
   prefix).

Tests: cell-merges-invalid-range added to TestSheetCreateValidatesBeforeCreating
Document (asserts calls == 0); TestFirstNonEmptySheetSpecCell covers both
startCell and start_cell. Changed-code coverage stays at 100%; targeted sheet
suites pass; CHANGELOG has a single Changed section with no false breaking claim.
2026-08-07 20:26:12 +08:00
huwenjiao.hwj dbecf23bc4 test(sheet): cover --sheets read-back error paths to reach 100% changed-code coverage
Adds coverage for the branches introduced by the per-sheet read-back:
resolveSheetIDsByName's RPC-error and unparseable-response paths, the create
--sheets path surfacing a list-fetch failure with the nodeId, and the
single-value data row in sheetSpecGrid. Changed-code coverage back to 100%.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj d73e199d97 fix(sheet): read back each sheet after --sheets table_put to catch silent data loss
The --values branch already reads back its first non-empty cell after writing,
to defend against the new-document initialization race where a write returns
success but the data does not land. The --sheets branch called table_put and
reported success with no read-back, so the same race would let the command exit
successfully while one or more sheets silently lost their initial data.

After table_put, the --sheets branch now:
- re-fetches get_all_sheets to build a name -> sheetId map (table_put reuses the
  renamed default sheet and auto-creates the rest by name), and
- for every spec that actually has content, reads back its first expected
  non-empty cell and fails if the read-back is empty or the sheet is missing.

firstNonEmptySheetSpecCell mirrors firstNonEmptyValuesCell: it treats columns as
the header row followed by data rows, honours start_cell, and returns
hasContent=false for a name-only spec so a legitimately empty sheet is not
misreported as data loss. Failures carry the nodeId and point at
sheet table-put for recovery, matching the --values branch's error shape.

Tests:
- TestSheetCreateWithSheetsVerifiesEachSheetLanded covers an empty read-back
  (errors, naming the sheet + nodeId + table-put), a sheet missing from the
  post-write listing, and a name-only sheet that must not trigger a read-back.
- TestFirstNonEmptySheetSpecCell covers header/data origins, an empty first
  header cell, a start_cell offset, and content-less specs.
- Existing --sheets tests updated for the added get_all_sheets + per-sheet
  read-back calls.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 7c9687094f refactor(sheet): declare create and export as mcp with their primary interface_ref
Reverts the interface_mode of sheet create and sheet export from composite back
to mcp with a single interface_ref, matching upstream/main and the existing
convention for multi-tool leaves (doc.create_document declares mcp +
create_document even though it also calls update_document).

Rationale:
- interface_ref is audit / traceability metadata; nothing reads it at runtime
  (verified: rebuilding with a bogus interface_ref still routes to the correct
  tool). Declaring the primary tool and treating the orchestration as an
  implementation detail is the pattern main already uses.
- sheet export was mcp + submit_export_job on main; it already orchestrated
  submit_export_job + query_export_job without declaring the poll. Adding an
  --export-format csv branch does not change that shape, so it does not warrant
  flipping to composite.
- sheet create was a genuine single-RPC command on main (create_workspace_sheet
  only). The --values / --sheets / --styles orchestration I added runs after the
  document exists; per the doc.create_document precedent it stays mcp.

This takes the sheet schema-compatibility failures from 4 to 0 without a waiver
or an admin override: the declarations now equal main's.

Also updates the wording of the seven new mapping-exclusion reasons for these
two commands (submit_export_job CSV params, create_workspace_sheet
values/sheets/styles) from "Composite ... input" to "Wrapper ... input", so the
reason text no longer collides with the interface_mode value now that both
leaves are mcp. The reasons are otherwise unchanged and still describe where
each value actually goes. range_batch_set_style keeps its "Composite" wording
because it genuinely stays interface_mode=composite.

Removes the two composite entries for these leaves from the interface
disposition contract test.

No execution path changes; targeted sheet suites, the disposition contract test,
and the schema-compat policy tests all pass; check-schema-catalog is green;
sheet-scoped schema-compatibility reports 0 failures.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 05d8c86177 policy(schema-compat): accept reviewed property clearing and mcp ref redirects
Two compatibility carve-outs, written alongside the existing interface_type
retirement allowance. Both cover declarative provenance metadata that nothing
reads at runtime: the tool a leaf invokes is decided in the CLI source, so a
stale interface_ref or property misinforms a reader rather than misrouting a
call. Neither carve-out can mask a change to the surface callers depend on.

Cleared property through a reviewed mapping exclusion. A leaf whose backing RPC
moves to a nested payload has no honest flat property to publish. The two
alternatives are worse: keep naming a field the request no longer contains, or
let assembly fall back to flag_name_inference and publish a name that appears in
no request at all. Accepted only when the old value was non-empty, the new value
is empty, and the new value resolved through reviewed_mapping_exclusion. A
redirect to a different non-empty value, a clearing by inference or native
annotation, a clearing with no recorded source, and populating a previously
empty property all stay incompatible. The exclusion table cannot be abused to
wave arbitrary clearing through: internal/cli/schema_parameter_bindings.go
verifies every parameter claiming an exclusion really does deliver an empty
property, and every entry carries a non-empty reviewed reason.

Redirected interface_ref with an unchanged CLI contract. Accepted only when
interface_mode is unchanged and stays mcp, both refs are non-empty, and no other
compatibility failure was recorded for that tool. That last condition is the
operative definition of "the contract is unchanged" — it is measured, not
asserted, so it automatically covers a lost parameter, a newly required one, a
moved type / default / format / enum, a tightened constraint, a positional or
dry_run change, and any effect / risk / confirmation / idempotency move. Any one
of them re-reports the redirect, so a surface change cannot ride along behind a
backend move. Moving to or from composite is a change in kind rather than a
redirect and stays reported; so does removing a ref outright.

Deliberately still incompatible: mcp -> composite with the ref dropped. That is
a leaf declaring it now orchestrates several RPCs, which is a semantic upgrade
rather than a like-for-like substitution, and it belongs in review.

For this branch the two carve-outs take schema-compatibility from 17 changed
fields to 4 — the twelve sheet.range_set_style property clearings and its
update_range -> set_cell_range redirect are now accepted. The remaining four are
the interface_mode plus interface_ref pairs on sheet.create_workspace_sheet and
sheet.submit_export_job.

Tests: TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion and
TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect assert the accepted
shapes plus twelve neighbouring shapes that must stay incompatible; the drift
table gains cases for clearing without an exclusion and redirecting despite one.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 115dad3b82 fix(sheet): keep JSON integer literals intact through both data channels
P1 from automated review. --values decoded with plain json.Unmarshal, so every
number became a float64 and integers beyond 2^53 were rounded before anything
was written. The read-back only checks that the probe cell is non-empty, so the
corruption was reported as a successful write. Order numbers and snowflake IDs
are ordinary spreadsheet data.

Measured before the fix:

  1234567890123456789   -> 1234567890123456768    snowflake id, tail rewritten
  12345678901234567890  -> 12345678901234567000   20-digit order number
  9007199254740993      -> 9007199254740992       2^53+1

--sheets had the same defect, which the review did not mention: its records and
data are forwarded verbatim to table_put, and the float64 round trip rewrote
1234567890123456789 as 1234567890123456800 before the request left the CLI.

Both channels now decode with json.Decoder.UseNumber, and cellToString emits a
json.Number through its String method so no float conversion happens on the way
to CSV. --styles keeps plain Unmarshal on purpose: its numbers are font sizes and
pixel dimensions, already constrained to int32 by pickNum, with no large-integer
case.

Tests assert the payload the CLI actually sends, not a recomputed decode:
- TestSheetCreatePreservesLargeIntegerLiterals checks the csv argument of
  set_range_from_csv and the marshalled table_put arguments. Both halves also
  assert the rounded forms are absent, so removing UseNumber fails the test
  instead of passing on a lucky substring match.
- TestCellToStringKeepsJSONNumberVerbatim covers large, negative, fractional and
  exponent literals, and keeps the existing float64 behaviour for other callers.

The shared scriptedToolCaller keeps only the last call, and the write is the
fourth of five, so the assertion needs an intermediate call. Rather than extend
that shared helper, this adds a callRecorder local to this file: InitDeps takes
the edition.ToolCaller interface, so embedding *scriptedToolCaller and
overriding CallTool is enough.

Changed-code coverage stays at 100.0000% (850 statements) per
check-coverage-gate.sh --changed-only.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj b8ac9810f4 fix(sheet): require unique --sheets names and cover the size error branches
Two things, both in the create-with-data path.

Unique worksheet names. parseCreateSheetSpecs accepted a --sheets payload with
repeated names, so table_put created several worksheets sharing one name. The
style tools locate a worksheet by "id or name", so --styles would then land on
whichever duplicate the server picked, and --styles runs after the document
already exists and cannot be rolled back. Duplicates are now refused before
anything is created, naming the first occurrence:

  --sheets[1].name="一月" 与 --sheets[0] 重复;工作表名必须唯一,...

Case-only differences are still accepted: the server distinguishes them and the
CLI should not tighten that. --styles needs no equivalent check because it
already requires name equality with the corresponding --sheets entry, and those
are now unique.

Coverage. The previous commit added a precise pickNum error path to the standard
and auto branches of planSizes, but no test reached it: the existing cases used
an integer size, which stops at "不能同时给 size" before the numeric check runs.
The CI coverage gate therefore reported 99.7619% on changed code
(sheet_create_with_data.go:512-514 and :521-523). Two cases now drive
type=standard and type=auto with size 28.5.

That pair is not only about the percentage. It pins the error precedence: a
fractional size must report "size=28.5 必须是整数", which points at the field
actually written wrong, rather than the generic "不能同时给 size". Swapping the
two checks would make the message misleading and now fails the tests.

Changed-code coverage measured locally at 100.0000% (845/845 statements), with
no uncovered blocks in the diff against upstream/main.

Tests:
- TestParseCreateSheetSpecsRejectsDuplicateNames covers adjacent, non-adjacent
  and {"sheets":[...]}-wrapped duplicates, plus three payloads that must pass.
- Three new cases in TestSheetCreateValidatesBeforeCreatingDocument
  (sheets-duplicate-name and the two fractional sizes), each asserting calls == 0.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj c1a90c0194 fix(sheet): reject fractional and out-of-range --styles sizes instead of truncating
P2 from automated review. pickNum ran int(n) straight on the float64 that JSON
decoding always produces, so font_size: 12.9 and row_sizes.size: 28.5 were
silently rewritten to 12 and 28 and then executed as a valid configuration. Both
the help text and the error messages state these fields must be positive
integers, and --styles is a non-atomic sequence that cannot be rolled back, so
truncation left a sheet that did not match what the caller asked for.

Measured before the fix:

  font_size=12.9  -> emitted fontSize=12
  size=28.5       -> emitted pixelSize=28
  size=1e20       -> emitted pixelSize=9223372036854775807

The overflow case was worse than reported: int(1e20) saturates to MaxInt64 and
was still sent.

pickNum now returns an error and rejects a non-integral value, a NaN or Inf, a
magnitude outside int32, and a non-numeric type. A missing key and an explicit
null still report "not provided" without an error, so optional fields keep
working. Every call site propagates the error, which means the whole --styles
payload is refused before the document is created. Confirmed through the real
CLI: font_size=12.9, col_sizes size=120.5 and size=1e20 all fail with a specific
message while font_size=12 still passes.

Tests:
- TestPickNumRejectsNonIntegralAndOutOfRange covers eight rejected inputs plus
  five accepted ones, the missing key, an explicit null, and alias-key lookup.
- Four new cases in TestSheetCreateValidatesBeforeCreatingDocument for
  cell_styles font_size, row_sizes size, col_sizes size and the overflow, each
  asserting calls == 0.
- TestPickStrAndPickNum updated: a bool value now surfaces a type error with
  ok=true rather than being reported as absent.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 0bb2b6ce98 feat(sheet): CSV export, style extensions and create-with-data
Adds four capabilities and, for the style surface, moves to the interface that
can actually express them.

Added:
- sheet create --values / --sheets / --styles: create a workbook and populate it
  in one command. --values takes a 2D array into the default sheet, --sheets
  takes typed tables across several sheets, --styles carries cell_styles /
  row_sizes / col_sizes / cell_merges. Every structure and enum is validated
  before the document is created, so an invalid config never leaves an orphan
  empty document behind.
- sheet export --export-format csv: synchronous single-sheet RFC4180 export with
  --sheet-id, --range and --value-render-option. --output writes to a file (a
  directory gets sheet-export.csv), otherwise the CSV goes to stdout while the
  truncation warning goes to stderr, keeping stdout pipeable.
- sheet update-dimension --size-type: pixel / standard (restore the default row
  height or column width) / auto (fit row height to content, ROWS only).
- sheet replace --match-formula: search and replace inside formula text.
- sheet range set-style --font-style / --font-line / --font-family /
  --border-styles-json.
- sheet range batch-set-style --ranges: stamp one style across several
  sheet-qualified ranges.

Changed (breaking Schema change, no CLI break):
- sheet range set-style moves from update_range to set_cell_range. The
  update_range style channel exposes exactly eight properties
  (backgroundColors, fontSizes, horizontalAlignments, verticalAlignments,
  fontColors, fontWeights, wordWrap, numberFormat) and has no slot for italic,
  underline/line-through, font family or borders, so the four new dimensions are
  not expressible there. interface_ref becomes set_cell_range and the twelve
  style flags stop publishing a flat property, because the value now lands in
  cells[i][j].cellStyles.* with no single top-level field to name.
- sheet range batch-set-style submits one atomic batch_update instead of looping
  update_range, so a partial failure no longer leaves half the ranges stamped.
  --continue-on-error becomes a server passthrough. Caps the fan-out at 100
  ranges and 200000 cells in aggregate.
- sheet export and sheet create declare interface_mode=composite: both route
  across several tools depending on the flags, so a single mcp ref was wrong.

Schema hygiene:
- Nineteen parameters that previously resolved through flag_name_inference into
  property names present in no request (bgColor, exportFormat, values, ...) are
  now reviewed mapping exclusions with a stated reason, so Schema omits the
  property instead of inventing one.

schema-compatibility reports 17 changed fields: 13 on sheet.range_set_style
(interface_ref plus twelve property mappings) and 2 each on
sheet.submit_export_job and sheet.create_workspace_sheet (interface_mode plus
interface_ref). No CLI flag is removed and no command path changes; the same
invocation runs on both the old and the new binary. Landing this needs a
decision on the Schema contract break.

Tests: targeted sheet suites in internal/helpers and the interface disposition
contract tests in internal/app pass; make build and gofmt clean;
check-schema-catalog, check-generated-drift, check-command-surface,
check-skill-commands and check-runtime-confirmation-truth all pass.
2026-08-07 20:25:21 +08:00
github-actions[bot] 2662f87ad0 Merge pull request #908 from liyuan333/feat/html-import-hints
feat(doc): fall back to upload chain for non-importable import formats
2026-08-07 19:58:57 +08:00
liyuan 5a5b567eb0 Merge remote-tracking branch 'upstream/main' into feat/html-import-hints
# Conflicts:
#	CHANGELOG.md
2026-08-07 19:27:49 +08:00
liyuan c2db909bd2 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 19:17:33 +08:00
liyuan 871542ef0c 评审意见修改 2026-08-07 19:17:25 +08:00
github-actions[bot] 2c7d0f3ac4 Merge pull request #907 from dxb121/codex/multi-im-shortcuts-hardening
feat(chat): harden multi-IM shortcuts and pagination
2026-08-07 18:43:01 +08:00
栩朝 5a345228eb fix(chat): address pagination and audit review feedback 2026-08-07 18:26:39 +08:00
xiatian ea7c66b190 docs(sheet): align mono and multi skill references
Replace the oversized mono Sheet reference with the progressive routing layout, mirror all Sheet topic references across both bundles, and add a paired-tree drift guard.
2026-08-07 17:43:21 +08:00
栩朝 b2b6153424 fix(chat): preserve flag-list size schema default 2026-08-07 17:19:36 +08:00
栩朝 59efb4facb feat(chat): harden multi-IM shortcuts and pagination 2026-08-07 17:19:36 +08:00
liyuan333 3605d4f450 Merge branch 'main' into feat/html-import-hints 2026-08-07 17:17:16 +08:00
liyuan f34b7741d4 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 17:09:21 +08:00
liyuan 2c573ec892 评审意见修改 2026-08-07 17:07:28 +08:00
github-actions[bot] 3027337a34 Merge pull request #901 from DingTalk-Real-AI/codex/ai-table-shortcut
feat(aitable): expose and verify complete AI Table shortcut surface
2026-08-07 16:41:11 +08:00
liyuan333 d0ad33034e Merge branch 'main' into feat/html-import-hints 2026-08-07 16:36:33 +08:00
liyuan 5f13876e6e fix(doc): address import fallback review — shared prechecks, clean upload primitive, marked JSON envelope 2026-08-07 16:25:19 +08:00
liyuan 116117e987 feat(doc): fall back to upload chain for non-importable import formats 2026-08-07 14:07:35 +08:00
前津 a3773c4384 Merge remote-tracking branch 'upstream/main' into feat/chat-message-help-id-chain 2026-08-06 18:11:41 +08:00
前津 4110330575 fix(skills): keep chat route within context budget 2026-08-06 18:00:32 +08:00
前津 2bd6b297b0 docs(chat): document post-send ID chain 2026-08-06 16:43:56 +08:00
126 changed files with 15861 additions and 3451 deletions
+71
View File
@@ -6,15 +6,85 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.2] - 2026-08-10
### Added
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
### Changed
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
so callers can reuse returned task, message, and conversation IDs instead
of searching message history by content.
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
reference with the progressive routing layout, aligns all 20 Sheet topic
references across the mono and multi bundles, and adds a content-policy guard
that prevents the paired topic trees from drifting again.
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
## [1.0.58-beta.1] - 2026-08-07
### Added
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
now supports image URLs and local-file uploads through explicit message
types, while retaining Markdown as the default and preserving its existing
title and text requirements.
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
commands to list, add, hide, sort, and manage custom conversation shortcuts,
with validation and confirmation for destructive removal.
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
Base, table, record, attachment, view, dashboard, and workflow operations
with explicit confirmation and result-verification semantics for writes.
### Changed
- **Doc import upload fallback** — `dws doc import` no longer fails on file
formats outside the conversion whitelist (html, pdf, zip, extensionless,
and any future format): it now hands the file to the document-space upload
chain (the same primitive as `dws drive upload --workspace`), stores the
original file at the requested `--folder`/`--workspace` target, and prints
an explicit stderr notice with the supported-format list and the
convert-to-md alternative. The fallback shares the import file checks
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
a single JSON document, and marks the machine-readable result with
`fallback: "upload"` and `converted: false` so agents never mistake the
stored file for a converted online document. The fallback fails closed
unless the commit response parses as JSON and carries a file identity
(exposed as `dentry_id`); empty or unverifiable responses surface as
errors instead of fabricated success. Importable formats and
`dws sheet import` validation are unchanged.
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
- **Release-equivalent PR compatibility gate** (#889) — pull-request
admission now runs command-surface compatibility checks against the current
release baseline before code reaches `main`.
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
used for new ready PRs while retaining the existing current-head review and
required-check gates.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
the registered remote tool name `verify_formula`; the previous
`formula_verify` name failed at gateway dispatch.
- **CLI and parameter recovery boundaries** (#864) — command and parameter
recovery now fail closed when an Agent-provided path or flag cannot be
reconciled with the executable CLI surface, reducing unsafe hallucinated
retries.
## [1.0.57-beta.4] - 2026-08-06
@@ -149,6 +219,7 @@ and compatibility and CI reliability fixes.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57-beta.4"
version "1.0.58-beta.1"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-arm64.tar.gz"
sha256 "34f7dabf1a97ed950c1cd331e5fb873a62c87632db249ca1b42bd2da925927f0"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-amd64.tar.gz"
sha256 "c812ad070ac76f8ab5aefe2ae1b9cdf9217389b383296acb36070fc2f40b9890"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-arm64.tar.gz"
sha256 "7dd5e38647c88e4b7936e0f41315cd612128faeb7960f50b28dbbd1fa9683d68"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-amd64.tar.gz"
sha256 "b384671440a62f1e5dca5dcdb49e51ef1aad816b601021ba363c8c69986ebd2f"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-skills.zip"
sha256 "2cf96aa2d35a01a4b92db2bd2d1760476d62d7528de5ec50a997c9a7713c3513"
end
def install
+1
View File
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@python3 scripts/run_chat_shortcut_live_audit_test.py
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
+7 -6
View File
@@ -70,16 +70,17 @@
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(5)
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
+1 -1
View File
@@ -2681,7 +2681,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
"availability": "available"
},
{
+1 -1
View File
@@ -101,5 +101,5 @@ skills/mono/
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
+13 -2
View File
@@ -2,7 +2,7 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
## 1. 质检矩阵
@@ -11,7 +11,8 @@
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
+1 -1
View File
@@ -4,7 +4,7 @@
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
+18
View File
@@ -173,6 +173,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
)
if err != nil {
t.Fatalf("flag-list dry-run error = %v", err)
}
if len(attempts) != 0 {
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
}
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
t.Fatalf("flag-list dry-run preview = %#v", preview)
}
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
}
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
@@ -48,7 +48,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
@@ -212,7 +212,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
{
canonical: "sheet.range_batch_set_style",
mode: "composite",
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
},
{
canonical: "sheet.create_with_data",
mode: "composite",
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
},
{
canonical: "sheet.range_read",
+2 -2
View File
@@ -568,9 +568,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +flag-list",
Aliases: map[string]string{
"limit": "size",
"limit": "page-size",
},
Blocked: []string{"count", "max", "max-results", "max-size", "page", "page-size", "per-page"},
Blocked: []string{"count", "max", "max-results", "max-size", "page", "per-page"},
},
{
CLIPath: "chat +group-members",
+2 -2
View File
@@ -148,7 +148,7 @@
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
@@ -361,7 +361,7 @@
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
@@ -592,10 +592,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
+30 -12
View File
@@ -91,7 +91,10 @@ func resolveNativeChatTarget(raw string) (string, error) {
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
const (
maxConversationCategoryTitleRunes = 15
chatFavoritesMaxPageSize = 30
)
func validatedConversationCategoryTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
@@ -2039,6 +2042,11 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
返回值与后续操作:
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
dws chat message query-send-status --open-task-id <openTaskId>
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
本地图片 / 文件消息:
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
@@ -3301,7 +3309,10 @@ func newChatCommand() *cobra.Command {
chatMessageQuerySendStatusCmd := &cobra.Command{
Use: "query-send-status",
Short: "查询消息发送状态",
Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`,
Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。
发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为
chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。`,
Example: ` dws chat message query-send-status --open-task-id <openTaskId>
# openTaskId 由 dws chat message send 返回`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3348,8 +3359,11 @@ func newChatCommand() *cobra.Command {
Short: "撤回用户发送的消息",
Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`,
Example: ` dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取`,
# 发送后撤回:send -> query-send-status -> recall
dws chat message send --group <openConversationId> --text "待撤回的内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
@@ -3406,12 +3420,16 @@ func newChatCommand() *cobra.Command {
推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。
也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`,
Example: ` dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
# 发送后编辑:send -> query-send-status -> edit
dws chat message send --group <openConversationId> --text "原始内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId> --text "更新后的内容"
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --title "标题" --text "更新后的内容"
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@all> 请查看" --at-all
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids <openDingTalkId1>
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取`,
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
@@ -7467,16 +7485,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
Example: ` dws chat message list-favorites
dws chat message list-favorites --size 50
dws chat message list-favorites --cursor 20 --size 20`,
dws chat message list-favorites --size 30
dws chat message list-favorites --cursor 20 --size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
}
size, _ := cmd.Flags().GetInt("size")
if size < 1 || size > 100 {
return apperrors.NewValidation("--size must be between 1 and 100")
if size < 1 || size > chatFavoritesMaxPageSize {
return apperrors.NewValidation("--size must be between 1 and 30")
}
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
"cursor": cursor,
@@ -7515,7 +7533,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
})
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)")
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
+4 -4
View File
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
caller := &chatFavoritesCaller{}
err := executeChatFavoritesCommand(t, caller,
"message", "list-favorites", "--cursor", "42", "--size", "50")
"message", "list-favorites", "--cursor", "42", "--size", "30")
if err != nil {
t.Fatalf("list-favorites returned error: %v", err)
}
want := map[string]any{"cursor": int64(42), "size": "50"}
want := map[string]any{"cursor": int64(42), "size": "30"}
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
}
}
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
for _, size := range []string{"-1", "0", "101"} {
for _, size := range []string{"-1", "0", "31"} {
t.Run(size, func(t *testing.T) {
caller := &chatFavoritesCaller{}
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
t.Fatalf("error = %v, want size validation error", err)
}
if got := apperrors.ExitCode(err); got != 3 {
@@ -0,0 +1,92 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"strings"
"testing"
)
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
tests := []struct {
name string
command string
contains []string
notContain string
}{
{
name: "send returns task ID",
command: "send",
contains: []string{
"openTaskId",
"query-send-status --open-task-id <openTaskId>",
},
},
{
name: "query returns message and conversation IDs",
command: "query-send-status",
contains: []string{
"openTaskId",
"openMessageId",
"openConversationId",
"chat message edit",
"chat message recall",
},
},
{
name: "edit includes post-send workflow",
command: "edit",
contains: []string{
"send -> query-send-status -> edit",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
{
name: "recall includes post-send workflow",
command: "recall",
contains: []string{
"send -> query-send-status -> recall",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", test.command, "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
}
help := output.String()
for _, want := range test.contains {
if !strings.Contains(help, want) {
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
}
}
if test.notContain != "" && strings.Contains(help, test.notContain) {
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
}
})
}
}
+4 -3
View File
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
_ = isEmptyDataValidation(value)
_ = isEmptyHyperlink(value)
}
_ = fillIntMatrix(2, 3, 4)
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
_ = maxColLen2D([][]int{{1}, {2, 3}})
_ = checkMatrixShape(1, 2, 1, 2, "x")
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
{styleSpec{}, 1, 1},
}
for _, style := range styles {
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
}
for _, tc := range []struct {
scalar, raw string
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
{"", `[[""]]`, hAlignEnum},
{"", `[["left"]]`, hAlignEnum},
} {
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
}
}
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
_, _ = findFilterViewByID(views, "one")
+40
View File
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
return callMCPTool("commit_uploaded_file", commitArgs)
}
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
// 兼容告警,调用方负责结果投影。
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
step1Args := map[string]any{}
if folder != "" {
step1Args["folderId"] = folder
}
if workspace != "" {
step1Args["workspaceId"] = workspace
}
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
if err != nil {
return "", err
}
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
if err != nil {
return "", err
}
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
return "", err
}
commitArgs := map[string]any{
"uploadKey": uploadKey,
"name": fileName,
"fileSize": float64(fileSize),
}
if folder != "" {
commitArgs["folderId"] = folder
}
if workspace != "" {
commitArgs["workspaceId"] = workspace
}
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
}
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
var data map[string]any
@@ -3764,6 +3801,9 @@ CLI 内部自动完成全部流程:
xlsx, xls → 电子表格
md, txt → 文字文档
xmind, mark → 脑图
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
为 md;上传到钉盘请用 dws drive upload
文件大小限制: 20MB
+125 -1
View File
@@ -58,6 +58,11 @@ type importFlowConfig struct {
timeoutAsResult bool
nextCommand string
poll importPollPolicy
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
uploadFallback bool
}
type preparedImportFile struct {
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
workspaceFlags: []string{"workspace", "workspace-id"},
nextCommand: "dws doc import get --task-id %s",
poll: defaultImportPollPolicy(),
// 白名单外的格式改走文档空间的文件上传链路
// (与 drive upload --workspace 同一条 doc-space 上传原语),
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
uploadFallback: true,
}
}
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
}
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if !cfg.supportedFormats[extension] {
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
// uploadFallback 配置的白名单外文件继续走完共享校验,由
// runImportCommand 分派到上传回退。
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
}
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
return callMCPToolReturnText(ctx, toolName, args)
}
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
// 在线文档转换。
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
label := file.extension
if label == "" {
label = "无扩展名"
}
deps.Out.PrintWarning(fmt.Sprintf(
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
label, cfg.supportedFormatsText))
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
uploadName := file.name
if filepath.Ext(uploadName) == "" && file.extension != "" {
uploadName += "." + file.extension
}
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
"fallback": "upload",
"converted": false,
"file": file.path,
"name": uploadName,
"format": file.extension,
"size": file.size,
})
}
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
deps.Out.PrintKeyValue("文件", file.path)
deps.Out.PrintKeyValue("名称", uploadName)
deps.Out.PrintKeyValue("格式", file.extension)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
return nil
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if !jsonMode {
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
}
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
if err != nil {
return err
}
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
commit, dentryID, err := parseUploadCommitResult(text)
if err != nil {
return err
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
"fallback": "upload",
"converted": false,
"name": uploadName,
"format": file.extension,
"dentry_id": dentryID,
"result": commit,
})
}
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
// 由调用方向用户提示核对入库结果,而不是伪装成功。
func parseUploadCommitResult(text string) (map[string]any, string, error) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
}
payload := parsed
if inner, ok := parsed["result"].(map[string]any); ok {
payload = inner
}
for _, key := range uploadCommitIDKeys {
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
return parsed, v, nil
}
}
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
}
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
file, err := prepareImportFile(cmd, args, cfg)
if err != nil {
return err
}
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
return runImportUploadFallback(cmd, cfg, file)
}
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
@@ -0,0 +1,379 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
var (
errUploadInfo = errors.New("upload info failed")
errUploadPut = errors.New("put failed")
)
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
t.Helper()
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
cmd := &cobra.Command{Use: "import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "", "")
cmd.Flags().String("folder-id", "", "")
cmd.Flags().String("workspace-id", "", "")
if filePath != "" {
if err := cmd.Flags().Set("file", filePath); err != nil {
t.Fatalf("set import file: %v", err)
}
}
return cmd
}
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
uploadSteps := []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
}
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
installScriptedCaller(t, caller)
var stdout, warnings bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = &warnings
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
t.Fatal(err)
}
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
}
if got := caller.args["workspaceId"]; got != "ws-1" {
t.Fatalf("commit workspaceId = %v, want ws-1", got)
}
if got := caller.args["name"]; got != "sales.html" {
t.Fatalf("commit name = %v, want original file name with extension", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
}
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
t.Fatalf("fallback markers missing: %#v", payload)
}
if payload["dentry_id"] != "node-1" {
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
}
if payload["requested_operation"] != "导入本地文件为在线文档" {
t.Fatalf("requested_operation = %v", payload["requested_operation"])
}
if !strings.Contains(warnings.String(), "文件上传链路") {
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
}
if strings.Contains(warnings.String(), "deprecated") {
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
}
})
t.Run("json dry run stays a single json document", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
}
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
t.Fatalf("dry-run fallback payload = %#v", payload)
}
})
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
}
}
})
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
path := writeImportFixture(t, "HTM")
cmd := htmlFallbackCommand(t, "")
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
}
})
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
t.Fatal(err)
}
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if got := caller.args["folderId"]; got != "folder-abc" {
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
}
})
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
var warnings bytes.Buffer
deps.Out.errW = &warnings
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
noExt := filepath.Join(t.TempDir(), "README")
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, noExt)
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if !strings.Contains(warnings.String(), "无扩展名") {
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
}
})
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
big := filepath.Join(t.TempDir(), "big.html")
f, err := os.Create(big)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(importMaxFileSize + 1); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, big)
err = runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
}
})
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
empty := filepath.Join(t.TempDir(), "empty.html")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, empty)
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
}
})
t.Run("importable formats keep the conversion path", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
}
if payload["operation"] != "导入本地文件为在线文档" {
t.Fatalf("operation = %v, want conversion path", payload["operation"])
}
if _, ok := payload["fallback"]; ok {
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
}
})
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
cmd := htmlFallbackCommand(t, "")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "--file is required") {
t.Fatalf("runImportCommand() error = %v, want --file required", err)
}
})
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
}
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
}
})
t.Run("upload chain errors propagate", func(t *testing.T) {
cases := []struct {
name string
steps []scriptedToolStep
putErr error
wantErr string
}{
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
}
})
}
})
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
cases := []struct {
name string
commit string
wantErr string
}{
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: tc.commit},
}})
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
}
})
}
})
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
}
if payload["dentry_id"] != "nested-node-9" {
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
}
})
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
}
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
t.Fatal(err)
}
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
}
})
}
+7 -1
View File
@@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command {
命令结构:
dws sheet create 创建钉钉表格文档
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
dws sheet list 获取全部工作表列表
dws sheet info 获取指定工作表详情
dws sheet new 新建工作表
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
dws sheet template list 获取表格模板列表
dws sheet template search 搜索表格模板
@@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command {
},
},
})
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
createWithDataCmd := newSheetCreateWithDataCmd()
exportCsvCmd := newSheetExportCsvCmd()
importCmd := newSheetImportCmd()
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
@@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
for _, cmd := range standaloneCmds {
+5 -5
View File
@@ -517,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command {
}
operations := make([]any, 0, len(ranges))
for i, rng := range ranges {
idx := strings.Index(rng, "!")
if idx <= 0 || idx == len(rng)-1 {
return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng)
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
if err != nil {
return err
}
sheetName := strings.TrimSpace(rng[:idx])
rangeAddr := strings.TrimSpace(rng[idx+1:])
operations = append(operations, map[string]any{
"toolName": "clear_range",
"input": map[string]any{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--match-case 区分大小写(默认 false)
--match-entire-cell 完整单元格匹配(默认 false)
--use-regexp 使用正则表达式匹配(默认 false)
--match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值)
--include-hidden 包含隐藏行/列(默认 false)
--range 限定替换范围,A1 表示法(不传时在整个工作表中替换)
@@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
toolArgs["matchEntireCell"] = matchEntireCell
useRegExp, _ := cmd.Flags().GetBool("use-regexp")
toolArgs["useRegExp"] = useRegExp
matchFormula, _ := cmd.Flags().GetBool("match-formula")
toolArgs["matchFormulaText"] = matchFormula
includeHidden, _ := cmd.Flags().GetBool("include-hidden")
toolArgs["includeHidden"] = includeHidden
return callMCPTool("replace_all", toolArgs)
@@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
},
Parameters: []contract.ParamDecl{
{Name: "find", Property: "text"},
{Name: "match-formula", Property: "matchFormulaText"},
{Name: "node", Property: "nodeId"},
{Name: "replacement", Property: "replaceText"},
{Name: "use-regexp", Property: "useRegExp"},
@@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)")
replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)")
replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
appendCmd := &cobra.Command{
+59 -9
View File
@@ -3,12 +3,21 @@ package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。
func sizeTypeEnumHint(dimension string) string {
if dimension == "ROWS" {
return "pixel / standard / auto"
}
return "pixel / standard"
}
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
func newDimensionCmds() []*cobra.Command {
@@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command {
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始
支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。
--hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。
当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
--pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。
尺寸模式(--size-type,对齐飞书):
pixel 默认,按 --pixel-size 指定的像素值设置
standard 恢复默认行高/列宽,无需 --pixel-size
auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size
常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`,
Example: ` # 隐藏第 3~4 行
dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden
@@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
}
hiddenChanged := cmd.Flags().Changed("hidden")
pixelSizeChanged := cmd.Flags().Changed("pixel-size")
if !hiddenChanged && !pixelSizeChanged {
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个")
sizeType, _ := cmd.Flags().GetString("size-type")
sizeType = strings.ToLower(strings.TrimSpace(sizeType))
// 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard
switch {
case sizeType == "" || sizeType == "pixel" || sizeType == "standard":
case sizeType == "auto" && dimension == "ROWS":
case sizeType == "auto":
return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)")
default:
return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType)
}
// standard/auto 由服务端决定尺寸,无需 --pixel-size
sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto"
if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize {
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)")
}
// standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素,
// 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。
if sizeTypeDrivesSize && pixelSizeChanged {
return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType)
}
// 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。
if sizeType == "pixel" && !pixelSizeChanged {
return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type")
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
@@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
}
toolArgs["pixelSize"] = pixelSize
}
if sizeType != "" {
toolArgs["sizeType"] = sizeType
}
return callMCPTool("update_dimension", toolArgs)
},
}
@@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000")
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)")
groupDimensionCmd := &cobra.Command{
Use: "group-dimension",
@@ -2,6 +2,7 @@ package helpers
import (
"io"
"strings"
"testing"
"github.com/spf13/cobra"
@@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T)
}
}
// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是
// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作
// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG),
// 用测试钉住,避免哪天改回宽松解析没人发现。
func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"}
for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} {
locator := []string{"--position", "1"}
if cmdName == "update-dimension" {
locator = []string{"--start-index", "1", "--hidden"}
}
for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} {
args := append(append(append([]string{}, common...), locator...), "--length", bad)
err := executeDimensionCoverage(t, cmdName, args...)
if err == nil {
t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad)
continue
}
if !strings.Contains(err.Error(), "--length") {
t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err)
}
}
}
}
// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。
func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"}
for _, tc := range []struct {
name string
dimension string
sizeType string
wantErr string
}{
// standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size
{"rows-standard", "ROWS", "standard", ""},
{"rows-auto", "ROWS", "auto", ""},
{"columns-standard", "COLUMNS", "standard", ""},
{"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"},
// 非法枚举的提示文案必须按维度给出不同的合法值列表
{"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"},
{"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"},
} {
t.Run(tc.name, func(t *testing.T) {
args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType)
err := executeDimensionCoverage(t, "update-dimension", args...)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
}
})
}
// pixel 仍需 --hidden 或 --pixel-size 之一
args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel")
if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil {
t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错")
}
}
func TestSizeTypeEnumHint(t *testing.T) {
if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" {
t.Fatalf("ROWS hint = %q", got)
}
if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" {
t.Fatalf("COLUMNS hint = %q", got)
}
}
func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
@@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T)
t.Fatalf("multi-select dropdown: %v", err)
}
}
// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个
// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。
func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) {
for _, tc := range []struct {
name string
flags [][2]string
want string
}{
{
"standard-with-pixel-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}},
"不能同时指定 --pixel-size",
},
{
"auto-with-pixel-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}},
"不能同时指定 --pixel-size",
},
{
"explicit-pixel-without-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}},
"--size-type pixel 必须配合 --pixel-size",
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
err := runUpdateDimensionForTest(t, caller, flags)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls)
}
})
}
// 合法组合仍然放行
for _, tc := range []struct {
name string
flags [][2]string
}{
{"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}},
{"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}},
{"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}},
{"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}},
{"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}},
} {
t.Run("ok/"+tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
if err := runUpdateDimensionForTest(t, caller, flags); err != nil {
t.Fatalf("合法组合被拒: %v", err)
}
})
}
}
func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error {
t.Helper()
installScriptedCaller(t, caller)
installSheetProductArgs(t)
for _, c := range newDimensionCmds() {
if c.Name() != "update-dimension" {
continue
}
for _, kv := range flags {
if err := c.Flags().Set(kv[0], kv[1]); err != nil {
t.Fatalf("set --%s: %v", kv[0], err)
}
}
return c.RunE(c, nil)
}
t.Fatal("update-dimension not found")
return nil
}
+241
View File
@@ -0,0 +1,241 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// ============================================================================
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
//
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
//
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
// ============================================================================
func newSheetExportCsvCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "export-csv",
Short: "导出单个工作表为纯 CSV(同步)",
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
参数说明:
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
sheet-export.csv);不传则把 CSV 打到 stdout
--allow-truncated 允许数据被截断时仍然导出
截断行为:
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
落盘保证:
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
权限要求:
当前用户对目标表格具备可查看权限。`,
Example: ` # 导出第一个工作表到 stdout(可管道处理)
dws sheet export-csv --node NODE_ID
# 导出指定工作表为本地 CSV 文件
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
# 只导出某个范围,取原始值
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
RunE: runSheetExportCsv,
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "export_csv",
CanonicalPath: "sheet.export_csv",
CLIPath: "sheet export-csv",
PrimaryCLIPath: "sheet export-csv",
},
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
},
Selection: contract.SelectionSpec{
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
Examples: []string{
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "range", Property: "range"},
{Name: "sheet-id", Property: "sheetId"},
{Name: "value-render-option", Property: "valueRenderOption"},
},
},
})
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
return cmd
}
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
var valueRenderOptionEnum = map[string]bool{
"formatted_value": true, "raw_value": true, "formula": true,
}
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
nodeID := mustGetFlag(cmd, "node")
if nodeID == "" {
return fmt.Errorf("flag --node is required")
}
sheetID, _ := cmd.Flags().GetString("sheet-id")
rangeAddr, _ := cmd.Flags().GetString("range")
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
}
outputPath, _ := cmd.Flags().GetString("output")
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
deps.Out.PrintKeyValue("节点", nodeID)
if sheetID != "" {
deps.Out.PrintKeyValue("工作表", sheetID)
}
if outputPath != "" {
deps.Out.PrintKeyValue("输出", outputPath)
}
return nil
}
ctx := context.Background()
toolArgs := map[string]any{
"nodeId": nodeID,
"annotateRowNumbers": false,
}
if sheetID != "" {
toolArgs["sheetId"] = sheetID
}
if rangeAddr != "" {
toolArgs["range"] = rangeAddr
}
if valueRenderOption != "" {
toolArgs["valueRenderOption"] = valueRenderOption
}
// CSV 正文走 stdout,进度/警告一律不能污染它。
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
if err != nil {
return fmt.Errorf("读取 CSV 失败: %w", err)
}
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
if err != nil {
return err
}
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
// 必须显式加 --allow-truncated。
if hasMore && !allowTruncated {
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
"或确认可接受不完整数据后加 --allow-truncated",
firstNonEmpty(outputPath, "stdout"))
}
if hasMore {
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
}
if outputPath == "" {
deps.Out.PrintRaw(csvContent)
return nil
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
outputPath = filepath.Join(outputPath, "sheet-export.csv")
}
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
// 失败时原文件保持不变。
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
}
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", err)
}
if hasMore {
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
return nil
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
}
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
//
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
var data map[string]any
if e := json.Unmarshal([]byte(text), &data); e != nil {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
}
if raw, wrapped := data["result"]; wrapped {
result, ok := raw.(map[string]any)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
}
data = result
}
raw, exists := data["csv"]
if !exists {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
}
csvVal, ok := raw.(string)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
}
csv = csvVal
if hm, ok := data["hasMore"].(bool); ok {
hasMore = hm
}
return csv, hasMore, nil
}
+353
View File
@@ -0,0 +1,353 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"github.com/spf13/pflag"
)
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = oldArgs })
cmd := newSheetExportCsvCmd()
for index := 0; index < len(args); index += 2 {
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
t.Fatalf("set %s: %v", args[index], err)
}
}
return runSheetExportCsv(cmd, nil)
}
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
// 成功。这里钉住两条命令的 flag 面互不渗透。
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
xlsxOnly := []string{}
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
sort.Strings(xlsxOnly)
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
}
csvFlags := map[string]bool{}
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
if !csvFlags[name] {
t.Errorf("sheet export-csv 缺少 flag --%s", name)
}
}
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
if csvFlags["export-format"] {
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
}
}
func TestSheetExportCsvRequiresNode(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeSheetExportCsv(t, caller)
if err == nil || !strings.Contains(err.Error(), "--node is required") {
t.Fatalf("err = %v, want required node", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0", caller.calls)
}
}
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
if err == nil ||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
t.Fatalf("err = %v, want enum rejection", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
}
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
}
}
}
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
t.Fatalf("csv dry run: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
}
}
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
// 截断默认失败,所以要显式放行才能走到落盘/输出。
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
t.Fatalf("csv export: %v", err)
}
for _, kv := range [][2]string{
{"range", "A1:Z1000"},
{"sheetId", "SHEET_1"},
{"valueRenderOption", "raw_value"},
} {
if got := caller.args[kv[0]]; got != kv[1] {
t.Errorf("%s 未透传: %#v", kv[0], got)
}
}
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
if caller.args["annotateRowNumbers"] != false {
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
}
}
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil {
t.Fatal("截断时未放行应报错,而非静默成功")
}
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误信息缺少 %q: %v", want, err)
}
}
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(body) != "PREEXISTING\n" {
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
}
}
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
t.Fatalf("allow-truncated 应允许导出: %v", err)
}
body, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q", string(body))
}
}
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
t.Fatalf("完整读取不应要求放行: %v", err)
}
body, err := os.ReadFile(out)
if err != nil || string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
}
}
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
dir := t.TempDir()
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
t.Fatalf("csv export: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
if err != nil {
t.Fatalf("read exported csv: %v", err)
}
if string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q", string(body))
}
}
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
out := filepath.Join(t.TempDir(), "existing.csv")
const original = "原有重要数据\n1,2,3\n"
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
previousRename := atomicRename
t.Cleanup(func() { atomicRename = previousRename })
atomicRename = func(string, string) error { return errors.New("no space left on device") }
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
t.Fatalf("err = %v, want write failure", err)
}
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(body) != original {
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
}
// 临时文件不能留在目录里冒充导出产物。
entries, readDirErr := os.ReadDir(filepath.Dir(out))
if readDirErr != nil {
t.Fatalf("read dir: %v", readDirErr)
}
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("目录残留临时文件: %v", names)
}
}
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
t.Fatalf("err = %v, want write failure", err)
}
}
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE")
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
t.Fatalf("err = %v, want read failure", err)
}
}
func TestParseGetRangeAsCsvResult(t *testing.T) {
// 兼容裸响应与 result 包装两种形状
for _, body := range []string{
`{"csv":"a,b\n","hasMore":true}`,
`{"result":{"csv":"a,b\n","hasMore":true}}`,
} {
csv, hasMore, err := parseGetRangeAsCsvResult(body)
if err != nil || csv != "a,b\n" || !hasMore {
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
}
}
// 字段存在但为空串是合法的(真的空区域)
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
if err != nil || csv != "" || hasMore {
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
}
}
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
for _, tc := range []struct{ body, want string }{
{"not json", "解析 get_range_as_csv 响应失败"},
{`{"message":"something odd"}`, "缺少 csv 字段"},
{`{"csv":123}`, "csv 字段不是字符串"},
{`{"csv":null}`, "csv 字段不是字符串"},
{`{"result":"not-an-object"}`, "result 不是对象"},
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
} {
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
!strings.Contains(err.Error(), tc.want) {
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
}
}
}
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
out := filepath.Join(t.TempDir(), "existing.csv")
const original = "原有重要数据\n1,2,3\n"
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
t.Fatalf("err = %v, want missing csv field", err)
}
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatal(readErr)
}
if string(body) != original {
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
}
}
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
installScriptedCaller(t, caller)
installSheetProductArgs(t)
var stdout, stderr bytes.Buffer
deps.Out.w, deps.Out.errW = &stdout, &stderr
cmd := newSheetExportCsvCmd()
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
t.Fatalf("set --%s: %v", kv[0], err)
}
}
if err := runSheetExportCsv(cmd, nil); err != nil {
t.Fatalf("csv export: %v", err)
}
if got := stdout.String(); got != "a,b\nc,d\n" {
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
}
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "CSV 已被截断") {
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
//
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
const budgetTestRange = "A1:Z1000"
func budgetTestRanges(n int) []string {
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
}
return out
}
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
t.Helper()
cmd := newRangeBatchSetStyleCmd()
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
t.Fatalf("set --bg-color: %v", err)
}
return cmd
}
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
within, err := json.Marshal(budgetTestRanges(7))
if err != nil {
t.Fatal(err)
}
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
if err != nil {
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
}
if len(ops) != 7 {
t.Fatalf("operations = %d, want 7", len(ops))
}
// 第 8 个把累计推到 208000,越过 200000 上限。
over, err := json.Marshal(budgetTestRanges(8))
if err != nil {
t.Fatal(err)
}
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
if err == nil {
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
}
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
}
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
writeBatch := func(t *testing.T, items []map[string]any) string {
t.Helper()
data, err := json.Marshal(items)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "styles.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
return path
}
item := func(i int) map[string]any {
return map[string]any{
"sheetId": fmt.Sprintf("Sheet%d", i+1),
"range": budgetTestRange,
"fontWeight": "bold",
}
}
// 累计预算:8 条 × 26000 = 208000 > 200000。
overBudget := make([]map[string]any, 0, 8)
for i := 0; i < 8; i++ {
overBudget = append(overBudget, item(i))
}
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
if err == nil {
t.Fatal("--batch 累计 208000 格必须被拒绝")
}
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
for i := 0; i < maxBatchStyleRanges+1; i++ {
tooMany = append(tooMany, map[string]any{
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
})
}
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
t.Fatalf("error = %v, want --batch item cap", err)
}
// 预算内的正常配置仍然照常展开。
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
if err != nil {
t.Fatalf("2 条应在预算内: %v", err)
}
if len(ops) != 2 {
t.Fatalf("operations = %d, want 2", len(ops))
}
}
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
var total int64
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
t.Fatalf("first range: %v", err)
}
if total != 26000 {
t.Fatalf("total = %d, want 26000", total)
}
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
var single int64
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
t.Fatal("1000x1000=1000000 必须超出累计预算")
}
}
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
probe := &cobra.Command{Use: "probe"}
bindStyleFlags(probe)
bound := map[string]bool{}
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
listed := map[string]bool{}
for _, name := range styleFlagNames {
if listed[name] {
t.Fatalf("styleFlagNames 重复登记 %q", name)
}
listed[name] = true
if !bound[name] {
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
}
}
for name := range bound {
if !listed[name] {
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
}
}
}
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchPath := filepath.Join(t.TempDir(), "styles.json")
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
cmd := newRangeBatchSetStyleCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
err := cmd.Execute()
if err == nil {
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
}
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
}
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
tooMany := make([]string, 0, maxBatchStyleRanges+1)
for i := 0; i <= maxBatchStyleRanges; i++ {
tooMany = append(tooMany, `"Sheet1!A1"`)
}
for _, tc := range []struct {
name string
ranges string
style bool
want string
}{
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
{
name: "over-range-cap",
ranges: "[" + strings.Join(tooMany, ",") + "]",
style: true,
want: "--ranges 最多 100 项",
},
{
name: "missing-sheet-prefix",
ranges: `["A1:B2"]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "blank-sheet-prefix",
ranges: `[" !A1:B2"]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "blank-range-after-prefix",
ranges: `["Sheet1! "]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "bad-range-address",
ranges: `["Sheet1!zz"]`,
style: true,
want: "解析失败",
},
{
name: "no-style-flag",
ranges: `["Sheet1!A1:B2"]`,
style: false,
want: "至少需要指定一个样式参数",
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newRangeBatchSetStyleCmd()
if tc.style {
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
t.Fatalf("set --bg-color: %v", err)
}
}
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
})
}
}
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
//
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchDir := t.TempDir()
writeBatch := func(t *testing.T, name, body string) string {
t.Helper()
p := filepath.Join(batchDir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
return p
}
for _, tc := range []struct {
name string
cmd func() *cobra.Command
args func(t *testing.T) []string
want string
}{
{
name: "set-style-ranges-blank-sheet",
cmd: newRangeBatchSetStyleCmd,
args: func(*testing.T) []string {
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
},
want: "必须包含工作表前缀",
},
{
name: "set-style-batch-blank-sheet-id",
cmd: newRangeBatchSetStyleCmd,
args: func(t *testing.T) []string {
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
return []string{"--node", "NODE_ID", "--batch", p}
},
want: "缺少 sheetId 或 range",
},
{
name: "set-style-batch-blank-range",
cmd: newRangeBatchSetStyleCmd,
args: func(t *testing.T) []string {
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
return []string{"--node", "NODE_ID", "--batch", p}
},
want: "缺少 sheetId 或 range",
},
{
name: "batch-clear-ranges-blank-sheet",
cmd: rangeBatchClearCoverageCommand,
args: func(*testing.T) []string {
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
},
want: "必须包含工作表前缀",
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
cmd := tc.cmd()
cmd.SetArgs(tc.args(t))
cmd.SilenceUsage, cmd.SilenceErrors = true, true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
})
}
}
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
p := filepath.Join(t.TempDir(), "padded.json")
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
ops, err := buildBatchStyleOpsFromFile(p)
if err != nil {
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
}
if len(ops) != 1 {
t.Fatalf("operations = %d, want 1", len(ops))
}
op, _ := ops[0].(map[string]any)
input, _ := op["input"].(map[string]any)
if input["sheetId"] != " Sheet1 " {
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
}
if input["rangeAddress"] != " A1:B2 " {
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
}
}
@@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
_, _, _ = parseA1Range(raw)
}
_ = fillStringMatrix(2, 2, "x")
_ = fillIntMatrix(2, 2, 1)
for _, spec := range []*styleSpec{
{FontSize: 1, FontSizesJSON: `[[1]]`},
{FontSize: -1},
{FontSize: 12},
{FontSizesJSON: `{`},
{FontSizesJSON: `[[1,2]]`},
{FontSizesJSON: `[[1]]`},
for _, tc := range []struct {
scalar int
jsonStr string
}{
{1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""},
} {
_ = applyFontSize(spec, 1, 1, map[string]any{})
if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
_, _ = get(9, 9)
}
}
for _, tc := range []struct {
scalar, raw string
@@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
{"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
{"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
} {
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
_, _ = get(9, 9)
}
}
for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`,
`{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} {
_, _ = parseBorderStyles(raw)
}
for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} {
_, _, _ = splitSheetPrefixedRange(raw, 0)
}
_ = maxColLenStr([][]string{{}, {"a", "b"}})
_ = maxColLen2D([][]int{{}, {1, 2}})
@@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
{styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
{styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
{styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
{styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1},
{styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1},
{styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1},
{styleSpec{FontFamily: "Arial"}, 1, 1},
{styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1},
{styleSpec{BorderStylesJSON: `{`}, 1, 1},
{styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top",
FontColor: "#000", FontWeight: "bold"}, 2, 2},
} {
_ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
_, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols)
}
}
+114 -7
View File
@@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
}
results, _ := payload["results"].([]any)
if len(results) != 1 {
t.Fatalf("JSON dry-run results = %#v", payload["results"])
// batch-set-style 现在组装为一次 batch_update 原子提交,
// dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。
if payload["tool"] != "batch_update" {
t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"])
}
entry, _ := results[0].(map[string]any)
if entry["dryRun"] != true || entry["tool"] != "update_range" {
t.Fatalf("JSON dry-run result = %#v", entry)
args, _ := payload["arguments"].(map[string]any)
ops, _ := args["operations"].([]any)
if len(ops) != 1 {
t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"])
}
op, _ := ops[0].(map[string]any)
if op["toolName"] != "set_cell_range" {
t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"])
}
} else {
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} {
if !strings.Contains(preview, want) {
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
}
@@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
}
// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默
// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键
// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用
// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。
func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) {
for _, tc := range []struct {
name string
json string
want string
}{
{"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`},
{"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`},
{"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`},
{"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"},
{"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"},
{"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"},
{"style-empty-string", `{"top":{"style":""}}`, "缺少 style"},
{"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"},
{"style-null", `{"top":{"style":null}}`, "缺少 style"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parseBorderStyles(tc.json)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") {
t.Fatalf("err = %v, want the edge name in the message", err)
}
})
}
// 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略)
for _, tc := range []struct {
name string
json string
wantColor any
}{
{"style-only", `{"top":{"style":"solid"}}`, nil},
{"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"},
{"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := parseBorderStyles(tc.json)
if err != nil {
t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err)
}
edge, _ := out["top"].(map[string]any)
if edge["style"] != "solid" {
t.Fatalf("style = %#v, want solid", edge["style"])
}
if edge["color"] != tc.wantColor {
t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor)
}
})
}
}
// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。
func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) {
const badBorder = `{"top":{"style":"solid","colour":"#f00"}}`
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchPath := filepath.Join(t.TempDir(), "styles.json")
fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]`
if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
for _, tc := range []struct {
name string
args []string
}{
{"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}},
{"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}},
{"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
cmd := newRangeSetStyleCmd()
if tc.name != "single-range" {
cmd = newRangeBatchSetStyleCmd()
}
cmd.SetArgs(tc.args)
cmd.SilenceUsage, cmd.SilenceErrors = true, true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) {
t.Fatalf("err = %v, want the unknown border field rejected", err)
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
})
}
}
@@ -12,7 +12,7 @@ import (
)
func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) {
if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil {
if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil {
t.Fatal("invalid font colors JSON returned nil")
}
+18 -7
View File
@@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
// become required; an existing path may not gain a new required flag,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
// changed. See reviewed.go.
//
// Comparing the effective local + inherited set is intentional. It catches a
// persistent flag whose scope is accidentally narrowed to its declaring
// command, while allowing a local flag to move to an ancestor without breaking
@@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
continue
}
if oldFlag.Type != newFlag.Type {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
// Resolve the exemption against the canonical path, not acceptedPath.
// An aliased command reaches this function once per accepted spelling,
// so keying on acceptedPath would let every alias spelling re-report a
// reviewed migration.
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
flagContractOtherwiseChanged(oldFlag, newFlag) {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
}
}
if !oldFlag.Required && newFlag.Required {
result.Blocking = append(result.Blocking, Change{
+224 -1
View File
@@ -13,7 +13,11 @@
package interfacesnapshot
import "testing"
import (
"os"
"regexp"
"testing"
)
func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) {
base := testSnapshot(
@@ -310,6 +314,225 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
}
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
t.Helper()
if _, exists := reviewedFlagTypeChanges[change]; exists {
t.Fatalf("fixture %+v collides with a production entry", change)
}
reviewedFlagTypeChanges[change] = struct{}{}
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
}
func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
// Without an entry the migration is still a blocking change.
if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") {
t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking)
}
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking)
}
}
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
// The reverse migration shares command and flag but not direction, so the
// string->int entry must not admit it.
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("int->string must not be admitted by a string->int entry")
}
}
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
// Same flag and direction, different command.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an entry for another command must not admit this migration")
}
// Same command and direction, different flag.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an entry for another flag must not admit this migration")
}
}
// A reviewed migration is only accepted when the rest of the flag's contract
// held still. Each case bundles one unrelated regression with the reviewed type
// change and requires the type failure to reappear, which also anchors
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
// drifts out of sync fails here instead of silently widening the exemption.
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
tests := []struct {
name string
base Flag
next Flag
kind string
}{
{
name: "became required",
base: Flag{Name: "query", Type: "string"},
next: Flag{Name: "query", Type: "int", Required: true},
kind: "flag_became_required",
},
{
name: "lost shorthand",
base: Flag{Name: "query", Type: "string", Shorthand: "q"},
next: Flag{Name: "query", Type: "int"},
kind: "flag_shorthand_changed",
},
{
name: "changed no-opt",
base: Flag{Name: "query", Type: "string", NoOpt: "all"},
next: Flag{Name: "query", Type: "int", NoOpt: "1"},
kind: "flag_no_opt_changed",
},
{
name: "became hidden",
base: Flag{Name: "query", Type: "string"},
next: Flag{Name: "query", Type: "int", Hidden: true},
kind: "flag_became_hidden",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base))
current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next))
comparison := Compare(current, base, "base")
if !hasChangeKind(comparison.Blocking, test.kind) {
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
}
if !hasChangeKind(comparison.Blocking, "flag_type_changed") {
t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking)
}
})
}
}
// The exemption is resolved against the canonical Command.Path, never the
// alias-expanded accepted path. An aliased command enters compareEffectiveFlags
// once per accepted spelling, so keying on the accepted path would let every
// alias spelling re-report a reviewed migration.
func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)),
)
// The canonical path admits the migration for every accepted spelling.
canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}
registerReviewedFixture(t, canonical)
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking)
}
delete(reviewedFlagTypeChanges, canonical)
// An entry spelled with the alias path matches nothing, because the lookup
// never sees the accepted spelling.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an alias-spelled entry must not admit the migration")
}
}
// A string -> int migration always changes the recorded default ("" to "0").
// Defaults are not part of what this gate enforces, and folding them into
// flagContractOtherwiseChanged would make every table entry dead on arrival.
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}),
)
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking)
}
}
// reviewedEntryPattern matches one table entry as gofmt writes it on a single
// line. It is used to read the sibling copy of the table out of the policy
// helper, which cannot be imported: that directory is copied into a worktree at
// a historical revision and built there, so it must not depend on this package.
var reviewedEntryPattern = regexp.MustCompile(
`\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`)
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
// The two copies of the table must stay identical, otherwise a migration passes
// one gate and is blocked by the other in the same CI job.
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
source, err := os.ReadFile(interfaceBaselineReviewedPath)
if err != nil {
t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err)
}
sibling := make(map[flagTypeChange]struct{})
for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) {
sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{}
}
// Without this the whole guard degrades into comparing two empty sets the
// moment the pattern stops matching, which is exactly how a duplicated
// allowlist goes stale unnoticed.
if len(sibling) == 0 {
t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath)
}
for change := range reviewedFlagTypeChanges {
if _, ok := sibling[change]; !ok {
t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath)
}
}
for change := range sibling {
if _, ok := reviewedFlagTypeChanges[change]; !ok {
t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath)
}
}
}
func testSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interfacesnapshot
// flagTypeChange identifies one flag type migration exactly: which command,
// which flag, and in which direction. Nothing is matched by wildcard — an entry
// that differs in any of the four fields does not apply.
//
// CommandPath is the canonical Command.Path form, which always includes the root
// command name ("dws minutes permission apply"). It is deliberately *not* the
// alias-expanded accepted path: a command reachable through an alias enters
// compareEffectiveFlags once per accepted spelling, and keying on the accepted
// path would let every alias spelling bypass the exemption and re-report the
// change.
//
// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and
// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline
// fails if they drift. The duplication is forced rather than chosen:
// check-authoritative-interface-baselines.sh copies the whole
// scripts/policy/interface-baseline directory into a worktree checked out at a
// historical revision and builds it there, so that copy cannot import a package
// this branch adds.
type flagTypeChange struct {
CommandPath string
Flag string
From string
To string
}
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
// migrations this gate accepts. A flag type is part of the published CLI
// contract, and a type swap cannot be proven safe from the type names alone —
// whether old invocations still parse depends on the concrete value domain the
// command enforces. So a migration is accepted only when this exact command,
// flag, and old→new pair appear here. Adding an entry is a contract decision
// and belongs in review, not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "int" is a
// separate key from "int" → "string" and only the reviewed direction is
// accepted. The reverse is not a candidate for this table — widening an int
// flag back to string lets values the parser used to reject reach RunE, which
// is a genuine loosening of validation rather than a type migration.
//
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
// only when nothing else about the flag moved. See compareEffectiveFlags.
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag parsed
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
// same [2,4] domain check in RunE.
//
// This is admissible because the historical set of *successful* invocations
// is a subset of the new one, not merely similar to it:
//
// - Old: the value had to parse as base-10 and land in [2,4]. That is
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
// ("+3", "03", "003", ...).
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
// resolves to the same number under base 0, so no historical success
// becomes a failure.
// - Base 0 additionally accepts "0x3" and friends, which the old parser
// rejected. That widens the accepted set; it cannot break a caller who
// was already succeeding.
// - Values outside [2,4] and non-numeric values still fail. Only the
// failure *message and timing* move, from RunE to flag parsing.
//
// The flag's default value changes from "" to "0" as an unavoidable
// consequence of the type. Neither gate compares defaults, and a default is
// not reachable by a caller here because RunE requires the flag to be
// explicitly set, so this is not a contract change.
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
}
// reviewedFlagTypeChange reports whether this exact command, flag and direction
// is a reviewed migration. Callers must additionally establish that the rest of
// the flag's contract is unchanged before skipping the failure.
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
CommandPath: commandPath,
Flag: flag,
From: from,
To: to,
}]
return reviewed
}
// flagContractOtherwiseChanged reports whether anything this gate checks about a
// flag, other than its type, differs between the two revisions. A reviewed type
// migration is only accepted when this returns false, so the exemption cannot
// carry an unrelated regression in behind it.
//
// Only the fields compareEffectiveFlags actually enforces are compared. Default
// is deliberately excluded: a string → int migration always changes it ("" to
// "0"), so including it would make every entry in the table dead. Deprecated is
// excluded for the same reason it is not a blocking change on its own.
func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
if !oldFlag.Required && newFlag.Required {
return true
}
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
return true
}
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
return true
}
if !oldFlag.Hidden && newFlag.Hidden {
return true
}
return false
}
+502 -35
View File
@@ -21,11 +21,18 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
chatSearchHardPageLimit = 500
chatSearchMaxWindowSize = 100
chatListAllHardPageLimit = 500
)
// ChatSearch searches groups by keyword (search_groups on the im server).
var ChatSearch = shortcut.Shortcut{
Service: "chat",
@@ -33,8 +40,8 @@ var ChatSearch = shortcut.Shortcut{
Aliases: []string{"+chat-group-search", "+search-group"},
SinglePositionalAliasFor: "query",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -48,15 +55,15 @@ var ChatSearch = shortcut.Shortcut{
CLIPath: "chat +chat-search",
PrimaryCLIPath: "chat +chat-search",
},
Description: "按关键词搜索群聊",
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按关键词搜索群聊",
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。"},
AgentSummary: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-search --query \"项目冲刺\""},
},
@@ -64,30 +71,327 @@ var ChatSearch = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量;显式页大小必须在 1-100 之间"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 Lark 对齐别名;显式页大小必须在 1-100 之间"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 Lark 对齐别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部群搜索分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size", "size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须在 1-100 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{`dws chat +chat-search --query "项目冲刺"`},
Execute: func(rt *shortcut.RuntimeContext) error {
query := rt.Str("query")
if query == "" {
query = rt.Str("keyword")
}
params := map[string]any{
"keyword": query,
"limit": rt.IntFirst("limit", "size"),
"cursor": rt.Str("cursor"),
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
return rt.CallMCP("search_groups", params)
Tips: []string{
`dws chat +chat-search --query "项目冲刺"`,
`dws chat +chat-search --query "项目" --page-size 100 --page-all --page-limit 20`,
},
Validate: validateChatSearch,
Execute: executeChatSearch,
}
func validateChatSearch(rt *shortcut.RuntimeContext) error {
if size := chatSearchPageSize(rt); size < 1 || size > 100 {
return apperrors.NewValidation("--limit/--page-size/--size 必须在 1-100 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatSearchHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func chatSearchPageSize(rt *shortcut.RuntimeContext) int {
if rt.Changed("page-size") {
return rt.Int("page-size")
}
if rt.Changed("size") {
return rt.Int("size")
}
return rt.Int("limit")
}
func chatSearchStartCursor(rt *shortcut.RuntimeContext) string {
if rt.Changed("page-token") {
if value := strings.TrimSpace(rt.Str("page-token")); value != "" {
return value
}
}
if value := strings.TrimSpace(rt.Str("cursor")); value != "" {
return value
}
return "0"
}
func chatSearchRequestParams(query string, pageSize int, cursor string, excludeMuted bool) map[string]any {
params := map[string]any{
"keyword": query,
"limit": pageSize,
"cursor": cursor,
}
if excludeMuted {
params["excludeMuted"] = true
}
return params
}
func executeChatSearch(rt *shortcut.RuntimeContext) error {
query := strings.TrimSpace(rt.StrFirst("query", "keyword"))
pageSize := chatSearchPageSize(rt)
requestPageSize := pageSize
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
cursor := chatSearchStartCursor(rt)
if rt.DryRun() {
return rt.CallMCP("search_groups", chatSearchRequestParams(query, pageSize, cursor, rt.Bool("exclude-muted")))
}
initialCursor := cursor
seenCursors := map[string]bool{cursor: true}
seenChats := map[string]bool{}
chats := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
maxWindowProbeUsed := false
completionEvidence := ""
for pagesFetched < pageLimit {
params := chatSearchRequestParams(query, requestPageSize, cursor, rt.Bool("exclude-muted"))
data, err := rt.CallMCPData("im", "search_groups", params)
if err != nil {
if pagesFetched == 0 {
return err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := chatSearchItems(data)
for _, chat := range pageItems {
id := strings.TrimSpace(fmt.Sprint(chat["openConversationId"]))
if id != "" && id != "<nil>" && seenChats[id] {
continue
}
if id != "" && id != "<nil>" {
seenChats[id] = true
}
chats = append(chats, chat)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor = chatSearchCursorString(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor != "":
pageHasMore = true
case len(pageItems) < requestPageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
// A full first page with hasMore=false/no cursor is the live legacy
// shape that can hide additional rows. Once a prior page supplied a
// valid continuation cursor, its terminal hasMore=false is trustworthy.
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor == "" &&
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
if fullPageWithoutCursor {
paginationKnown = false
if rt.Bool("page-all") && initialCursor == "0" && !maxWindowProbeUsed && requestPageSize < chatSearchMaxWindowSize && pagesFetched < pageLimit {
maxWindowProbeUsed = true
requestPageSize = chatSearchMaxWindowSize
cursor = initialCursor
stopReason = "max_window_probe"
continue
}
if !rt.Bool("page-all") {
complete = false
stopReason = "single_page_full_untrusted"
break
}
if pagesFetched >= pageLimit && requestPageSize < chatSearchMaxWindowSize {
truncatedByPageLimit = true
complete = false
stopReason = "page_limit"
break
}
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
})
complete = false
stopReason = "pagination_error"
break
}
complete = true
nextCursor = ""
if maxWindowProbeUsed {
paginationKnown = false
completionEvidence = "max_window_short_page"
stopReason = "max_window_short_page"
} else {
completionEvidence = "backend_has_more_false_short_page"
stopReason = "source_complete"
}
break
}
if nextCursor == "" || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索返回 hasMore=true,但 nextCursor 缺失或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
payload := map[string]any{
"query": query,
"count": len(chats),
"chats": chats,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(chats) > 0,
"requestedPageSize": pageSize,
"effectivePageSize": requestPageSize,
}
if completionEvidence != "" {
payload["completionEvidence"] = completionEvidence
}
if rt.Bool("exclude-muted") {
payload["filter"] = map[string]any{"excludeMuted": true}
}
if err := rt.Output(payload); err != nil {
return err
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("群搜索分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/search_groups"),
apperrors.WithReason("chat_search_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
func chatSearchItems(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
}
scopes := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range []string{"groups", "chats", "items", "list", "result"} {
if raw, ok := scope[key].([]any); ok {
return projectChatSearchItems(raw)
}
}
}
return []map[string]any{}
}
func projectChatSearchItems(raw []any) []map[string]any {
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
group, ok := item.(map[string]any)
if !ok {
continue
}
projected := make(map[string]any, len(group)+1)
for key, value := range group {
projected[key] = value
}
if _, exists := projected["openConversationId"]; !exists {
for _, key := range []string{"conversationId", "chatId", "id"} {
if value, ok := group[key]; ok {
projected["openConversationId"] = value
break
}
}
}
if _, exists := projected["name"]; !exists {
for _, key := range []string{"title", "conversationName"} {
if value, ok := group[key]; ok {
projected["name"] = value
break
}
}
}
out = append(out, projected)
}
return out
}
func chatSearchCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// ChatMembersList lists members of a group (get_group_members, chat server).
@@ -575,27 +879,190 @@ var ChatListAll = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量(最大 200)"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{`dws chat +chat-list-all --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +chat-list-all --limit 50`,
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
},
Validate: validateChatListAll,
Execute: executeChatListAll,
}
func validateChatListAll(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
return apperrors.NewValidation("--limit 必须在 1-200 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListAllHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if c := rt.Str("cursor"); c != "" && c != "0" {
params["cursor"] = c
}
return nil
}
func executeChatListAll(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
}
if c := rt.Str("cursor"); c != "" && c != "0" {
params["cursor"] = c
}
if rt.Bool("page-all") {
payload, err := readAllChatListAll(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
groups := chatListAllProject(data)
payload := map[string]any{"count": len(groups), "groups": groups}
chatmsg.ApplyPagination(payload, data)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
pageLimit := rt.Int("page-limit")
cursorValue := baseParams["cursor"]
cursorKey := chatListAllCursorString(cursorValue)
if cursorKey == "" {
cursorKey = "0"
}
seenCursors := map[string]bool{cursorKey: true}
seenGroups := map[string]bool{}
allGroups := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
if pagesFetched == 0 {
return nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "read_failure"
break
}
groups := chatListAllProject(data)
payload := map[string]any{"count": len(groups), "groups": groups}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
pagesFetched++
pageGroups := chatListAllProject(data)
for _, group := range pageGroups {
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
if id == "<nil>" {
id = ""
}
if id != "" && seenGroups[id] {
continue
}
if id != "" {
seenGroups[id] = true
}
allGroups = append(allGroups, group)
}
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = nil
stopReason = "source_complete"
break
}
nextCursor = page["nextCursor"]
nextKey := chatListAllCursorString(nextCursor)
if nextKey == "" || seenCursors[nextKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := map[string]any{
"count": len(allGroups), "groups": allGroups,
"pagesFetched": pagesFetched, "paginationKnown": true,
"complete": complete && len(failures) == 0, "hasMore": hasMore,
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures), "failures": failures,
"partial": len(failures) > 0 && len(allGroups) > 0,
}
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("已加入群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_my_groups_pagination"),
apperrors.WithReason("chat_list_all_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func chatListAllCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// chatListAllProject reshapes list_my_groups_pagination into a clean group list
@@ -6,6 +6,8 @@ package chat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"testing"
@@ -213,6 +215,19 @@ func TestCrossPlatformCoverageChatListProjectionHelpers(t *testing.T) {
if _, err := normalizeChatListTypes([]string{"bad"}); err == nil {
t.Fatal("invalid type accepted")
}
var tuple map[string]any
if err := json.Unmarshal([]byte(`{"result":[[{"openConversationId":"tuple"}],2,true]}`), &tuple); err != nil {
t.Fatal(err)
}
page, err := chatListPagination(tuple)
if err != nil || page["nextCursor"] != float64(2) || page["hasMore"] != true || len(chatListProject(tuple)) != 1 {
t.Fatalf("tuple normalization rows=%#v page=%#v err=%v", chatListProject(tuple), page, err)
}
ordinary := map[string]any{"result": []any{map[string]any{"openConversationId": "ordinary"}}}
page, err = chatListPagination(ordinary)
if err != nil || page != nil || len(chatListProject(ordinary)) != 1 {
t.Fatalf("ordinary list rows=%#v page=%#v err=%v", chatListProject(ordinary), page, err)
}
}
func TestCrossPlatformCoverageChatListExecuteAndHelperEdges(t *testing.T) {
@@ -317,3 +332,310 @@ func TestCrossPlatformCoverageChatListCursorAndPageSizeDirect(t *testing.T) {
t.Fatal("expected executeChatList cursor error")
}
}
func TestCrossPlatformCoverageChatListPageAllMergesBeforeTypeFilter(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"单聊","singleChat":true}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"重复单聊","singleChat":true},{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--types", "group", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
chat := payload["chats"].([]any)[0].(map[string]any)
if chat["openConversationId"] != "group-1" || chat["conversationType"] != "group" {
t.Fatalf("chat = %#v", chat)
}
}
func TestCrossPlatformCoverageChatListProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false}],"hasMore":false}}`,
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false},{"openConversationId":"group-2","title":"项目二群","singleChat":false}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListPageLimitPublishesContinuation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != float64(9) ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListLaterPageFailureKeepsPartialLedger(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_all_conversations": {`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":2}}`},
},
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
if _, err := chatListNextCursor(float64(1.5)); err == nil {
t.Fatal("fractional cursor unexpectedly accepted")
}
if _, err := chatListNextCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
}
func TestCrossPlatformCoverageChatListAdditionalPaginationEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_all_conversations": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"g1"}],"nextCursor":2}}`,
`{"result":{"conversations":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("gateway tuple preserves continuation metadata", func(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":[[{"openConversationId":"g1","singleChat":false}],2,true]}`,
`{"result":[[{"openConversationId":"g2","singleChat":false}],0,false]}`,
},
}}
payload, err := run(t, fake, "--page-size", "1", "--page-all")
if err != nil {
t.Fatalf("tuple pagination payload=%#v calls=%#v err=%v", payload, fake.calls, err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
t.Fatalf("tuple pagination calls = %#v", fake.calls)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["stopReason"] != "source_complete" {
t.Fatalf("tuple pagination payload = %#v", payload)
}
})
for name, response := range map[string]string{
"missing metadata": `{"result":[[{"openConversationId":"g1"}]]}`,
"invalid hasMore": `{"result":[[{"openConversationId":"g1"}],2,"true"]}`,
} {
t.Run("malformed gateway tuple "+name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": response,
}}, "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("malformed tuple payload=%#v err=%v", payload, err)
}
})
}
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}]}}`,
}}, "--page-size", "1")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("single full page is untrusted", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1")
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("bounded probe reports page limit", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("full maximum probe fails closed", func(t *testing.T) {
chats := make([]map[string]any, chatListMaxWindowSize)
for i := range chats {
chats[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
}
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"conversations": chats, "hasMore": false}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"first"}],"hasMore":false}}`,
string(second),
},
}}, "--page-size", "1", "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
wantErr bool
stop string
}{
{name: "missing continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
{name: "single page continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": tc.response}})
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("exclude muted and output errors", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+chat-list", "--exclude-muted"})
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
t.Fatalf("calls=%#v err=%v", fake.calls, err)
}
})
}
func TestCrossPlatformCoverageChatListCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "nil", value: nil, ok: true},
{name: "int", value: int(1), ok: true},
{name: "negative int", value: int(-1)},
{name: "int64", value: int64(2), ok: true},
{name: "negative int64", value: int64(-1)},
{name: "float64", value: float64(3), ok: true},
{name: "negative float64", value: float64(-1)},
{name: "json number", value: json.Number("4"), ok: true},
{name: "invalid json number", value: json.Number("bad")},
{name: "empty string", value: "", ok: true},
{name: "string", value: "5", ok: true},
{name: "invalid string", value: "bad"},
{name: "unsupported", value: struct{}{}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := chatListNextCursor(tc.value)
if (err == nil) != tc.ok {
t.Fatalf("value=%#v err=%v", tc.value, err)
}
})
}
}
+238 -37
View File
@@ -16,9 +16,12 @@ package chat
import (
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -27,6 +30,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const directMessagesHardPageLimit = 500
// MessagesSend sends a text/markdown message as the current user
// (send_personal_message, chat server). Media/file variants are not covered.
// MessagesReply quote-replies a message (send_personal_message, chat server).
@@ -283,8 +288,8 @@ var MessagesList = shortcut.Shortcut{
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
@@ -407,16 +412,6 @@ func listMessagesResolveMaps(data map[string]any) []map[string]any {
return out
}
// listMessagesFirst returns the first present candidate key's value.
func listMessagesFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// MessagesListDirect pulls messages of a single chat (list_individual_chat_message, chat server).
var MessagesListDirect = shortcut.Shortcut{
Service: "chat",
@@ -454,40 +449,246 @@ var MessagesListDirect = shortcut.Shortcut{
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "对方 openDingTalkId(与 --user 二选一)"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿毫秒级 nextCursor 自动读取全部单聊消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Tips: []string{`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
Tips: []string{
`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`,
`dws chat +messages-list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --forward=false --page-all`,
},
Execute: executeMessagesListDirect,
}
func validateMessagesListDirectPagination(rt *shortcut.RuntimeContext) error {
// This existing command shipped without formal Schema constraints. Keep its
// published constraint set stable and validate the new pagination options at
// runtime so adding --page-all remains backwards compatible for consumers.
for _, name := range []string{"limit", "size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return apperrors.NewValidation("--" + name + " 必须大于 0")
}
switch {
case rt.Str("open-dingtalk-id") != "":
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
default:
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > directMessagesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
return nil
}
func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
if err := validateMessagesListDirectPagination(rt); err != nil {
return err
}
params := map[string]any{
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
}
switch {
case rt.Str("open-dingtalk-id") != "":
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
default:
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
if rt.Bool("page-all") {
payload, err := readAllDirectMessages(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
if err != nil {
return err
}
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
payload := map[string]any{"count": len(messages), "messages": messages}
direction := "older"
if rt.Bool("forward") {
direction = "newer"
}
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, error) {
pageLimit := rt.Int("page-limit")
direction := "older"
if rt.Bool("forward") {
direction = "newer"
}
seenCursors := map[string]bool{}
seenMessages := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextPage map[string]any
for pagesFetched < pageLimit {
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
if err != nil {
return err
if pagesFetched == 0 {
return nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
})
stopReason = "read_failure"
break
}
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
payload := map[string]any{"count": len(messages), "messages": messages}
direction := "older"
if rt.Bool("forward") {
direction = "newer"
pagesFetched++
pageItems := listMessagesResolveMaps(data)
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
continue
}
if id != "" {
seenMessages[id] = true
}
allItems = append(allItems, item)
}
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
return rt.Output(payload)
},
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextPage = nil
stopReason = "source_complete"
break
}
if len(pageItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层返回 hasMore=true 但当前页没有消息",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := directMessageCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层返回 hasMore=true,但 nextCursor 无效: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息毫秒 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": direction, "time": boundary, "nextCursor": page["nextCursor"],
}
params["time"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
messages := make([]map[string]any, 0, len(allItems))
for _, item := range allItems {
messages = append(messages, listMessageProjectOneWithReactions(item, !rt.Bool("no-reactions")))
}
payload := chatmsg.NewMessageListPayload(messages)
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(messages) > 0
if hasMore && nextPage != nil {
payload["nextPage"] = nextPage
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("单聊消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/list_individual_chat_message"),
apperrors.WithReason("messages_list_direct_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
)
}
func directMessageCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
key := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return key, boundary, nil
}
// MessagesListTopicReplies pulls topic replies (list_topic_replies, chat server).
@@ -0,0 +1,328 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageChatSearchDryRunStopsBeforeRead(t *testing.T) {
fake := &larkAlignmentCaller{dryRun: true}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-search", "--query", "项目", "--page-size", "20",
"--cursor", "cursor-1", "--exclude-muted", "--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("chat-search dry-run made lower calls: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatSearchPageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"groups":[{"openConversationId":"cid-1","title":"重复项目群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != "0" || fake.calls[1].args["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
chats := payload["chats"].([]any)
if chats[0].(map[string]any)["name"] != "项目一群" || chats[1].(map[string]any)["openConversationId"] != "cid-2" {
t.Fatalf("chats = %#v", chats)
}
}
func TestCrossPlatformCoverageChatSearchProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":false}}`,
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatSearchPageTokenAndPageLimit(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":true,"nextCursor":"cursor-3"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-token", "cursor-2", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "cursor-3" ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatSearchFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/search_groups": {`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
},
failProductToolAt: map[string]int{"im/search_groups": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("missing pagination unexpectedly succeeded")
}
})
t.Run("short legacy page is bounded complete", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "不存在"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["paginationKnown"] != false || payload["stopReason"] != "legacy_short_page" {
t.Fatalf("payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageChatSearchPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-size", "0"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "c1", "--page-token", "c2"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageChatSearchAdditionalPaginationEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/search_groups": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"g1"}],"nextCursor":"next"}}`,
`{"result":{"groups":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("single full page is untrusted", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1")
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("bounded probe reports page limit", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("full maximum probe fails closed", func(t *testing.T) {
groups := make([]map[string]any, chatSearchMaxWindowSize)
for i := range groups {
groups[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
}
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"groups": groups, "hasMore": false}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"first"}],"hasMore":false}}`,
string(second),
},
}}, "--page-size", "1", "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
args []string
wantErr bool
stop string
}{
{name: "missing continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
{name: "single page continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":"next"}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/search_groups": tc.response}, failProductToolAt: map[string]int{}}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("exclude muted reaches lower call and output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--exclude-muted"})
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
t.Fatalf("calls=%#v err=%v", fake.calls, err)
}
})
}
func TestCrossPlatformCoverageChatSearchProjectionEdges(t *testing.T) {
if got := chatSearchItems(nil); len(got) != 0 {
t.Fatalf("nil items = %#v", got)
}
if got := chatSearchItems(map[string]any{"result": map[string]any{"unknown": true}}); len(got) != 0 {
t.Fatalf("unknown items = %#v", got)
}
got := projectChatSearchItems([]any{
"invalid",
map[string]any{"conversationId": "g1", "conversationName": "项目群"},
})
if len(got) != 1 || got[0]["openConversationId"] != "g1" || got[0]["name"] != "项目群" {
t.Fatalf("projected = %#v", got)
}
for _, value := range []any{nil, "", "0", "<nil>"} {
if cursor := chatSearchCursorString(value); cursor != "" {
t.Fatalf("cursor %v = %q", value, cursor)
}
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
}})
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--cursor="})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
}
@@ -316,7 +316,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
cases := [][]string{
{"chat", "+flag-create", "--message-ids", strings.Join(tooMany, ","), "--conversation-id", "cid", "--yes"},
{"chat", "+flag-list", "--cursor", "-1"},
{"chat", "+flag-list", "--size", "101"},
{"chat", "+flag-list", "--size", "31"},
{"chat", "+messages-mget", "--msg-ids", strings.Join(makeIDs(51), ",")},
{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--output-dir", "../escape"},
}
@@ -0,0 +1,274 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeRead(t *testing.T) {
fake := &larkAlignmentCaller{dryRun: true}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("flag-list dry-run made lower calls: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageFlagListPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_message_favorites": {
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"一"}],"hasMore":true,"nextCursor":7}}`,
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"重复"},{"openMessageId":"msg-2","openConversationId":"cid-2","summary":"二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != 0 || fake.calls[0].args["size"] != "1" || fake.calls[1].args["cursor"] != 7 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-2"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-token", "7", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != 7 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_message_favorites": {`{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`},
},
failProductToolAt: map[string]int{"im/list_message_favorites": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["partial"] != true || payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("stalled numeric cursor fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+flag-list", "--cursor", "7", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("stalled cursor unexpectedly succeeded")
}
})
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1"})
if err := root.Execute(); err == nil {
t.Fatal("full legacy page unexpectedly succeeded")
}
})
}
func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-size", "0"},
{"--page-size", "31"},
{"--size", "31"},
{"--page-token", "not-a-number"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "1", "--page-token", "2"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
if _, err := flagListNextCursor(float64(1.5)); err == nil {
t.Fatal("fractional cursor unexpectedly accepted")
}
if _, err := flagListNextCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
}
func TestCrossPlatformCoverageFlagListAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
for _, token := range []string{"", "0"} {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
}}, "--page-token="+token)
if err != nil || payload["complete"] != true {
t.Fatalf("token=%q payload=%#v err=%v", token, payload, err)
}
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_message_favorites": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_message_favorites": {
`{"result":{"items":[{"openMessageId":"m1"}],"nextCursor":2}}`,
`{"result":{"items":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
wantErr bool
stop string
}{
{name: "single page continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
{name: "missing continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_message_favorites": tc.response}})
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+flag-list"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
if got := flagListItems(nil); len(got) != 0 {
t.Fatalf("nil items = %#v", got)
}
if got := flagListItems(map[string]any{"result": map[string]any{"items": []any{"invalid", map[string]any{"id": "m1"}}}}); len(got) != 1 {
t.Fatalf("projected items = %#v", got)
}
if got := firstNonEmptyMapString(map[string]any{}, "missing"); got != "" {
t.Fatalf("missing identity = %q", got)
}
}
func TestCrossPlatformCoverageFlagListCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "nil", value: nil, ok: true},
{name: "int", value: int(1), ok: true},
{name: "negative int", value: int(-1)},
{name: "int64", value: int64(2), ok: true},
{name: "negative int64", value: int64(-1)},
{name: "float64", value: float64(3), ok: true},
{name: "negative float64", value: float64(-1)},
{name: "json number", value: json.Number("4"), ok: true},
{name: "invalid json number", value: json.Number("bad")},
{name: "empty string", value: "", ok: true},
{name: "string", value: "5", ok: true},
{name: "invalid string", value: "bad"},
{name: "unsupported", value: struct{}{}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := flagListNextCursor(tc.value)
if (err == nil) != tc.ok {
t.Fatalf("value=%#v err=%v", tc.value, err)
}
})
}
}
+559 -41
View File
@@ -7,6 +7,7 @@ package chat
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
@@ -16,6 +17,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
flagListHardPageLimit = 500
flagListMaxPageSize = 30
chatListHardPageLimit = 500
chatListMaxWindowSize = 100
)
// ChatCreate creates a DingTalk group after resolving every natural member and
// the optional owner to stable DingTalk identities. Description, initial-bot,
// idempotency, and Lark visibility semantics remain deliberately unsupported.
@@ -499,34 +507,291 @@ var FlagList = shortcut.Shortcut{
Service: "chat",
Command: "+flag-list",
Product: "im",
Description: "分页查询当前用户收藏的消息",
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"cursor", "size"},
Description: "--cursor 必须大于等于 0,--size 必须在 1-100 之间",
}},
Tips: []string{`dws chat +flag-list --cursor 0 --size 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 必须大于等于 0")
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +flag-list --cursor 0 --page-size 20`,
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
},
Validate: validateFlagList,
Execute: executeFlagList,
}
func validateFlagList(rt *shortcut.RuntimeContext) error {
if size := flagListPageSize(rt); size < 1 || size > flagListMaxPageSize {
return apperrors.NewValidation("--page-size/--size 必须在 1-30 之间")
}
if _, err := flagListStartCursor(rt); err != nil {
return err
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > flagListHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if size := rt.Int("size"); size < 1 || size > 100 {
return apperrors.NewValidation("--size 必须在 1-100 之间")
}
return nil
}
func flagListPageSize(rt *shortcut.RuntimeContext) int {
if rt.Changed("size") {
return rt.Int("size")
}
return rt.Int("page-size")
}
func flagListStartCursor(rt *shortcut.RuntimeContext) (int, error) {
if rt.Changed("page-token") {
token := strings.TrimSpace(rt.Str("page-token"))
if token == "" || token == "0" {
return 0, nil
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_message_favorites", map[string]any{
"cursor": rt.Int("cursor"),
"size": strconv.Itoa(rt.Int("size")),
})
},
value, err := strconv.Atoi(token)
if err != nil || value < 0 {
return 0, apperrors.NewValidation("--page-token 必须是非负整数")
}
return value, nil
}
if value := rt.Int("cursor"); value < 0 {
return 0, apperrors.NewValidation("--cursor 必须大于等于 0")
}
return rt.Int("cursor"), nil
}
func executeFlagList(rt *shortcut.RuntimeContext) error {
pageSize := flagListPageSize(rt)
cursor, _ := flagListStartCursor(rt) // Validate has already accepted the cursor.
if rt.DryRun() {
return rt.CallMCP("list_message_favorites", flagListRequestParams(cursor, pageSize))
}
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
seenCursors := map[int]bool{cursor: true}
seenMessages := map[string]bool{}
items := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := 0
var cursorErr error
stopReason := "source_complete"
truncatedByPageLimit := false
for pagesFetched < pageLimit {
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
if callErr != nil {
if pagesFetched == 0 {
return callErr
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := flagListItems(data)
for _, item := range pageItems {
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
if messageID != "" && seenMessages[messageID] {
continue
}
if messageID != "" {
seenMessages[messageID] = true
}
items = append(items, item)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor, cursorErr = flagListNextCursor(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor > 0:
pageHasMore = true
case len(pageItems) < pageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = 0
stopReason = "source_complete"
break
}
if cursorErr != nil || nextCursor <= 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
payload := map[string]any{
"count": len(items),
"items": items,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(items) > 0,
}
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("收藏消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_message_favorites"),
apperrors.WithReason("flag_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
func flagListRequestParams(cursor, pageSize int) map[string]any {
return map[string]any{
"cursor": cursor,
"size": strconv.Itoa(pageSize),
}
}
func flagListItems(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
}
scopes := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range []string{"items", "favorites", "messageFavorites", "list"} {
raw, ok := scope[key].([]any)
if !ok {
continue
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if value, ok := item.(map[string]any); ok {
out = append(out, value)
}
}
return out
}
}
return []map[string]any{}
}
func flagListNextCursor(value any) (int, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case int:
if typed < 0 {
return 0, fmt.Errorf("negative cursor")
}
return typed, nil
case int64:
if typed < 0 || int64(int(typed)) != typed {
return 0, fmt.Errorf("cursor out of range")
}
return int(typed), nil
case float64:
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
return 0, fmt.Errorf("invalid numeric cursor")
}
return int(typed), nil
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
return 0, fmt.Errorf("invalid json cursor")
}
return int(parsed), nil
case string:
text := strings.TrimSpace(typed)
if text == "" || text == "0" {
return 0, nil
}
parsed, err := strconv.Atoi(text)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("invalid string cursor")
}
return parsed, nil
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
func firstNonEmptyMapString(item map[string]any, keys ...string) string {
for _, key := range keys {
value := strings.TrimSpace(fmt.Sprint(item[key]))
if value != "" && value != "<nil>" {
return value
}
}
return ""
}
// FeedGroupQueryItem is a client-side exact filter over a DingTalk conversation
@@ -615,18 +880,18 @@ func feedGroupQueryProject(conversations []map[string]any, requestedIDs []string
// ChatList is the lark-cli +chat-list alignment for DingTalk. It lists the
// current user's conversations through list_all_conversations, defaults to
// groups only (lark omit-types behavior), and applies --types / --exclude-muted
// as an honest client-side subset over the current page. Sort order and bot
// groups only (lark omit-types behavior), and applies --types after all fetched
// pages have been merged. Sort order and bot
// identity p2p stripping are intentionally omitted: DingTalk has no matching
// sort_type parameter here, and DWS shortcuts run as the signed-in user.
var ChatList = shortcut.Shortcut{
Service: "chat",
Command: "+chat-list",
Product: "im",
Description: "列出当前用户加入的会话(默认群聊;可选包含单聊)",
Description: "分页列出当前用户加入的会话(默认群聊;可选包含单聊)",
Intent: "当你要像 lark-cli +chat-list 一样列出当前用户加入的会话时使用;默认只返回群聊," +
"--types 可追加 p2p(钉钉投影为 conversationType=direct)。" +
"--exclude-muted 交给下层 excludeMuted;--types 过滤发生在当前页本地,不会伪造跨页完整性。" +
"--exclude-muted 交给下层 excludeMuted;默认读取一页,明确要求全部时加 --page-all,合并并去重所有已读取页面后再执行 --types 过滤,结果公开完整性 ledger。" +
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
@@ -635,6 +900,8 @@ var ChatList = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部会话分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
},
Constraints: []shortcut.Constraint{
@@ -655,10 +922,12 @@ var ChatList = shortcut.Shortcut{
Flags: []string{"page-token"},
Description: "若提供则必须是非负整数",
},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +chat-list`,
`dws chat +chat-list --types group,p2p --exclude-muted --page-size 50`,
`dws chat +chat-list --types group --page-size 100 --page-all --page-limit 20`,
},
Validate: validateChatList,
Execute: executeChatList,
@@ -675,6 +944,14 @@ func validateChatList(rt *shortcut.RuntimeContext) error {
if _, err := chatListCursor(rt); err != nil {
return err
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
@@ -690,28 +967,269 @@ func executeChatList(rt *shortcut.RuntimeContext) error {
if err != nil {
return err
}
params := map[string]any{"limit": chatListPageSize(rt)}
if cursor > 0 {
params["cursor"] = cursor
pageSize := chatListPageSize(rt)
requestPageSize := pageSize
initialCursor := cursor
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
seenCursors := map[int]bool{cursor: true}
seenChats := map[string]bool{}
allChats := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := 0
stopReason := "source_complete"
truncatedByPageLimit := false
maxWindowProbeUsed := false
completionEvidence := ""
for pagesFetched < pageLimit {
params := map[string]any{"limit": requestPageSize}
if cursor > 0 {
params["cursor"] = cursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
data, callErr := rt.CallMCPData("im", "list_all_conversations", params)
if callErr != nil {
if pagesFetched == 0 {
return callErr
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := chatListProject(data)
for _, chat := range pageItems {
id := firstNonEmptyMapString(chat, "openConversationId")
if id != "" && seenChats[id] {
continue
}
if id != "" {
seenChats[id] = true
}
allChats = append(allChats, chat)
}
page, paginationErr := chatListPagination(data)
if paginationErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination", "error": paginationErr.Error(),
})
stopReason = "pagination_error"
break
}
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor, err = chatListNextCursor(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor > 0:
pageHasMore = true
case len(pageItems) < requestPageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
// A full first page with hasMore=false/no cursor is the live legacy
// shape that can hide additional rows. Once a prior page supplied a
// valid continuation cursor, its terminal hasMore=false is trustworthy.
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor <= 0 &&
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
if fullPageWithoutCursor {
paginationKnown = false
if rt.Bool("page-all") && initialCursor == 0 && !maxWindowProbeUsed && requestPageSize < chatListMaxWindowSize && pagesFetched < pageLimit {
maxWindowProbeUsed = true
requestPageSize = chatListMaxWindowSize
cursor = initialCursor
stopReason = "max_window_probe"
continue
}
if !rt.Bool("page-all") {
complete = false
stopReason = "single_page_full_untrusted"
break
}
if pagesFetched >= pageLimit && requestPageSize < chatListMaxWindowSize {
truncatedByPageLimit = true
complete = false
stopReason = "page_limit"
break
}
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
})
complete = false
stopReason = "pagination_error"
break
}
complete = true
nextCursor = 0
if maxWindowProbeUsed {
paginationKnown = false
completionEvidence = "max_window_short_page"
stopReason = "max_window_short_page"
} else {
completionEvidence = "backend_has_more_false_short_page"
stopReason = "source_complete"
}
break
}
if err != nil || nextCursor <= 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
return err
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
chats := chatListFilterTypes(chatListProject(data), types)
chats := chatListFilterTypes(allChats, types)
payload := map[string]any{
"count": len(chats),
"chats": chats,
"requestedTypes": types,
"count": len(chats),
"chats": chats,
"requestedTypes": types,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(chats) > 0,
"requestedPageSize": pageSize,
"effectivePageSize": requestPageSize,
}
if completionEvidence != "" {
payload["completionEvidence"] = completionEvidence
}
if rt.Bool("exclude-muted") {
payload["filter"] = map[string]any{"excludeMuted": true}
}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_all_conversations"),
apperrors.WithReason("chat_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
// chatListPagination preserves the shared map/envelope pagination formats and
// supplements them with the list_all_conversations gateway tuple:
// result:[conversationList,nextCursor,hasMore]. Tuple positions are specific to
// this RPC, so they are normalized here instead of broadening chatmsg.Pagination
// for unrelated Chat shortcuts.
func chatListPagination(data map[string]any) (map[string]any, error) {
page := chatmsg.Pagination(data)
for _, key := range []string{"result", "data"} {
values, ok := data[key].([]any)
if !ok || len(values) == 0 {
continue
}
if _, tuple := values[0].([]any); !tuple {
continue
}
if len(values) < 3 {
return nil, fmt.Errorf("会话列表 tuple 分页元数据不完整:期望 [conversationList,nextCursor,hasMore]")
}
hasMore, ok := values[2].(bool)
if !ok {
return nil, fmt.Errorf("会话列表 tuple 的 hasMore 必须是布尔值")
}
return map[string]any{
"nextCursor": values[1],
"hasMore": hasMore,
"complete": !hasMore,
}, nil
}
return page, nil
}
func chatListNextCursor(value any) (int, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case int:
if typed < 0 {
return 0, fmt.Errorf("negative cursor")
}
return typed, nil
case int64:
if typed < 0 || int64(int(typed)) != typed {
return 0, fmt.Errorf("cursor out of range")
}
return int(typed), nil
case float64:
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
return 0, fmt.Errorf("invalid numeric cursor")
}
return int(typed), nil
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
return 0, fmt.Errorf("invalid json cursor")
}
return int(parsed), nil
case string:
text := strings.TrimSpace(typed)
if text == "" || text == "0" {
return 0, nil
}
parsed, err := strconv.Atoi(text)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("invalid string cursor")
}
return parsed, nil
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
func chatListPageSize(rt *shortcut.RuntimeContext) int {
@@ -24,6 +24,7 @@ type larkAlignmentCall struct {
type larkAlignmentCaller struct {
calls []larkAlignmentCall
dryRun bool
failTarget string
failProductTool string
failProductToolAt map[string]int
@@ -97,7 +98,7 @@ func (f *larkAlignmentCaller) CallReadTool(ctx context.Context, product, tool st
}
func (f *larkAlignmentCaller) Format() string { return "json" }
func (f *larkAlignmentCaller) DryRun() bool { return false }
func (f *larkAlignmentCaller) DryRun() bool { return f.dryRun }
func (f *larkAlignmentCaller) Fields() string { return "" }
func (f *larkAlignmentCaller) JQ() string { return "" }
@@ -409,12 +410,12 @@ func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
},
{
name: "flag-list",
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "50"},
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "30"},
product: "im",
tool: "list_message_favorites",
wantArgs: map[string]any{
"cursor": 3,
"size": "50",
"size": "30",
},
},
}
@@ -705,7 +706,7 @@ func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNes
}
}
func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
func TestCrossPlatformCoverageChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -755,7 +756,7 @@ func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
}
}
func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
func TestCrossPlatformCoverageChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -798,7 +799,7 @@ func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
}
}
func TestChatListP2POnlyDropsGroups(t *testing.T) {
func TestCrossPlatformCoverageChatListP2POnlyDropsGroups(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -0,0 +1,396 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {
`{"result":{"groups":[{"openConversationId":"g1","title":"群一"}],"hasMore":true,"nextCursor":88}}`,
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群"},{"openConversationId":"g2","title":"群二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--limit", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != float64(88) {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *testing.T) {
const cursorMillis int64 = 1785919699136
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {
`{"result":{"messages":[{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(fake.calls) != 2 || fake.calls[1].args["time"] != wantBoundary {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinuation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
"--page-all", "--page-limit", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00", "--page-all",
})
if err := root.Execute(); err == nil {
t.Fatal("missing direct-message cursor unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllLaterReadFailureKeepsPartialLedger(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`},
},
failProductToolAt: map[string]int{"im/list_my_groups_pagination": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("later group-list failure unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-list-all", "--limit", "201"},
{"chat", "+chat-list-all", "--page-limit", "2"},
{"chat", "+chat-list-all", "--page-all", "--page-limit", "501"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-limit", "2"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"groups":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"groups":[],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}})
if err != nil || payload["stopReason"] != tc.stop || payload["pagesFetched"] != float64(1) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("first read failure", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_my_groups_pagination": 1}}, args...)
if err == nil {
t.Fatalf("first read failure unexpectedly succeeded for %v", args)
}
}
})
for _, tc := range []struct {
name string
response string
args []string
wantErr bool
stop string
}{
{name: "unknown pagination", response: `{"result":{"groups":[]}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "missing continuation", response: `{"result":{"groups":[],"hasMore":true}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "page limit", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("output error swallowed for args %v", args)
}
}
})
for _, value := range []any{nil, "", "0", "<nil>"} {
if got := chatListAllCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"title":"无 ID 群"}],"hasMore":false}}`,
}}, "--page-all")
if err != nil || payload["count"] != float64(1) {
t.Fatalf("missing-id payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageDirectMessagesAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
for _, args := range [][]string{
{"chat", "+messages-list-direct", "--time", "2026-01-01"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--limit", "0"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--size", "0"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
t.Run("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"messages":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"chat/list_individual_chat_message": tc.response}})
if err != nil || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("read failures", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}})
if err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
_, err = run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}}, "--page-all")
if err == nil {
t.Fatal("first all-page read failure unexpectedly succeeded")
}
payload, err := run(t, &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`},
},
failProductToolAt: map[string]int{"chat/list_individual_chat_message": 2},
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
}{
{name: "unknown pagination", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"}]}}`}},
{name: "empty continuing page", responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":1785919699136}}`}},
{name: "stalled cursor and duplicate", responses: []string{
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": tc.responses,
}}, "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("all-page output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "int", value: int(1), ok: true},
{name: "int32", value: int32(2), ok: true},
{name: "float32", value: float32(3), ok: true},
{name: "float64", value: float64(4), ok: true},
{name: "string", value: "5", ok: true},
{name: "fractional float32", value: float32(1.5)},
{name: "negative float64", value: float64(-1)},
{name: "invalid string", value: "invalid"},
{name: "unsupported", value: true},
{name: "zero", value: int64(0)},
} {
t.Run("cursor "+tc.name, func(t *testing.T) {
key, boundary, err := directMessageCursorBoundary(tc.value)
if tc.ok {
if err != nil || key == "" || boundary == "" {
t.Fatalf("cursor=(%q,%q,%v)", key, boundary, err)
}
return
}
if err == nil {
t.Fatalf("invalid cursor succeeded: (%q,%q)", key, boundary)
}
})
}
}
+59 -9
View File
@@ -32,10 +32,13 @@ package chatmsg
import (
"encoding/json"
"fmt"
"math"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// MessageListContractVersion identifies the additive, compatibility-preserving
@@ -75,6 +78,8 @@ var messageResultContractV1 = MessageResultContract{
"contractVersion",
"messages",
"count",
"resolvedFilters",
"queryRange",
"pagesFetched",
"paginationKnown",
"complete",
@@ -774,10 +779,10 @@ func ApplyPagination(payload, data map[string]any) {
}
}
// ApplyMessagePagination publishes message-list completeness without claiming
// the lower response's nextCursor is a valid CLI input. DingTalk's executable
// message-list contract paginates with the boundary message createTime, so the
// resume object uses exactly that accepted parameter.
// ApplyMessagePagination publishes message-list completeness and converts the
// authoritative millisecond nextCursor into the RFC3339Nano time boundary
// accepted by the executable message-list command. Projected createTime is
// deliberately not used because it is only second precision.
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
payload["contractVersion"] = MessageListContractVersion
payload["pagesFetched"] = 1
@@ -812,25 +817,70 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string]
payload["complete"] = value
}
hasMore, _ := page["hasMore"].(bool)
if !hasMore || len(messages) == 0 {
if !hasMore {
return
}
boundary := CreateTime(messages[len(messages)-1])
if boundary == nil {
if len(messages) == 0 {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime",
"error": "下层返回 hasMore=true 但当前页没有消息",
}}
return
}
next := map[string]any{"time": boundary}
_, boundary, err := messagePaginationCursorBoundary(page["nextCursor"])
if err != nil {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但 nextCursor 无效: " + err.Error(),
}}
return
}
next := map[string]any{"time": boundary, "nextCursor": page["nextCursor"]}
if strings.TrimSpace(direction) != "" {
next["direction"] = direction
}
payload["nextPage"] = next
}
func messagePaginationCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
key := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return key, boundary, nil
}
// Pagination extracts hasMore/nextCursor from the response root or a common
// result/data envelope. When hasMore is present it also emits the explicit
// inverse "complete", making truncation hard for an Agent to overlook.
+60 -4
View File
@@ -16,6 +16,7 @@ package chatmsg
import (
"strings"
"testing"
"time"
)
func TestCrossPlatformCoverageSender(t *testing.T) {
@@ -220,6 +221,46 @@ func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing
}
}
func TestCrossPlatformCoverageMessagePaginationCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "int", value: int(1), ok: true},
{name: "int32", value: int32(2), ok: true},
{name: "float32", value: float32(3), ok: true},
{name: "float64", value: float64(4), ok: true},
{name: "string", value: "5", ok: true},
{name: "fractional float32", value: float32(1.5)},
{name: "negative float64", value: float64(-1)},
{name: "invalid string", value: "not-a-cursor"},
{name: "unsupported", value: true},
{name: "zero", value: int64(0)},
} {
t.Run(tc.name, func(t *testing.T) {
key, boundary, err := messagePaginationCursorBoundary(tc.value)
if tc.ok {
if err != nil || key == "" || boundary == "" {
t.Fatalf("cursor = (%q, %q, %v)", key, boundary, err)
}
return
}
if err == nil {
t.Fatalf("invalid cursor unexpectedly succeeded: (%q, %q)", key, boundary)
}
})
}
payload := NewMessageListPayload(nil)
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{"hasMore": true, "nextCursor": int64(1)},
}, nil, "older")
if payload["failedCount"] != 1 || payload["complete"] != false {
t.Fatalf("empty continuing page = %#v", payload)
}
}
func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) {
got := QuotedMessage(map[string]any{
"quotedMessage": map[string]any{
@@ -423,26 +464,41 @@ func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
}
}
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
func TestCrossPlatformCoverageApplyMessagePaginationUsesAuthoritativeMillisecondCursor(t *testing.T) {
const cursorMillis int64 = 1785919699136
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{
"hasMore": true,
"nextCursor": "not-a-message-list-cli-flag",
"nextCursor": cursorMillis,
},
}, []map[string]any{
{"createTime": "2026-07-28 10:00:00"},
{"createTime": "2026-07-28 09:00:00"},
}, "older")
if _, leaked := payload["nextCursor"]; leaked {
t.Fatalf("message pagination exposed unusable cursor: %#v", payload)
t.Fatalf("message pagination exposed cursor outside nextPage: %#v", payload)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
next, ok := payload["nextPage"].(map[string]any)
if !ok || next["time"] != "2026-07-28 09:00:00" || next["direction"] != "older" {
if !ok || next["time"] != wantBoundary || next["nextCursor"] != cursorMillis || next["direction"] != "older" {
t.Fatalf("message nextPage = %#v", payload["nextPage"])
}
}
func TestCrossPlatformCoverageApplyMessagePaginationStopsWhenComplete(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{"hasMore": false},
}, nil, "older")
if payload["paginationKnown"] != true || payload["hasMore"] != false || payload["complete"] != true {
t.Fatalf("completed message pagination = %#v", payload)
}
if _, ok := payload["nextPage"]; ok {
t.Fatalf("completed message pagination unexpectedly exposed nextPage: %#v", payload)
}
}
func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older")
+4 -4
View File
@@ -360,12 +360,12 @@ var Import = shortcut.Shortcut{
Service: "doc",
Command: "+import",
Product: productDoc,
Description: "上传本地文件并等待转换成在线文档对象",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Description: "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
[]string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`}),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
+1 -1
View File
@@ -698,7 +698,7 @@
},
"+thread-replies": {
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
+1 -1
View File
@@ -10,7 +10,7 @@
"+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true},
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
+212 -45
View File
@@ -14,6 +14,7 @@
package smart
import (
"fmt"
"strconv"
"strings"
"time"
@@ -21,12 +22,18 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
atMeDefaultPageLimit = 50
atMeHardPageLimit = 500
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
//
// Steps:
@@ -93,74 +100,96 @@ var AtMe = shortcut.Shortcut{
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
`dws chat +at-me --group "项目群"`,
`dws chat +at-me --days 30 --page-all --page-limit 50`,
},
Validate: validateAtMe,
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := ""
directTarget := strings.TrimSpace(rt.Str("group"))
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
if directTarget != "" || queryTarget != "" {
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
if err != nil {
return err
}
groupID = resolved.Selected.OpenConversationID
}
Execute: executeAtMe,
}
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
days := rt.Int("days")
if days <= 0 {
days = 7
func executeAtMe(rt *shortcut.RuntimeContext) error {
groupID := ""
directTarget := strings.TrimSpace(rt.Str("group"))
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
if directTarget != "" || queryTarget != "" {
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
if err != nil {
return err
}
now := time.Now()
startMs := now.AddDate(0, 0, -days).UnixMilli()
endMs := now.UnixMilli()
groupID = resolved.Selected.OpenConversationID
}
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
// first-page defaults (limit 50, cursor "0") mirror
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
}
if groupID != "" {
params["openConversationId"] = groupID
// Step 1 — look-back window [now-Nd, now] in epoch millis. Validation has
// already constrained days to the published 1-3650 range.
days := rt.Int("days")
now := time.Now()
startMs := now.AddDate(0, 0, -days).UnixMilli()
endMs := now.UnixMilli()
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
// first-page defaults (limit 50, cursor "0") mirror
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
}
if groupID != "" {
params["openConversationId"] = groupID
}
var items []map[string]any
var payload map[string]any
var readErr error
if rt.Bool("page-all") {
payload, items, readErr = readAllAtMePages(rt, params)
if payload == nil {
return readErr
}
} else {
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
return err
}
// Step 3 — always publish the stable list envelope, including for an empty
// or newly-shaped response. This keeps common .messages[]/.items[] jq
// projections deterministic instead of turning absence into null.
items := atMeMessageItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions")))
}
payload := chatmsg.NewMessageListPayload(results)
payload["items"] = atMeCompatibilityItems(results)
items = atMeMessageItems(data)
payload = atMePayload(items, !rt.Bool("no-reactions"))
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
},
}
results := make([]map[string]any, 0, len(items))
if projected, ok := payload["messages"].([]map[string]any); ok {
results = projected
}
if rt.Bool("download-resources") && readErr == nil {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
}
if len(results) == 0 {
payload["messages"] = []map[string]any{}
payload["items"] = []map[string]any{}
}
if err := rt.Output(payload); err != nil {
return err
}
return readErr
}
func validateAtMe(rt *shortcut.RuntimeContext) error {
@@ -174,9 +203,147 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") <= 0 {
return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > atMeHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func atMePayload(items []map[string]any, includeReactions bool) map[string]any {
results := make([]map[string]any, 0, len(items))
for _, message := range items {
results = append(results, atMeProjectWithReactions(message, includeReactions))
}
payload := chatmsg.NewMessageListPayload(results)
payload["items"] = atMeCompatibilityItems(results)
return payload
}
func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), atMeDefaultPageLimit)
cursor := strings.TrimSpace(fmt.Sprint(baseParams["cursor"]))
if cursor == "" || cursor == "<nil>" {
cursor = "0"
}
seenCursors := map[string]bool{cursor: true}
seenMessages := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
for pagesFetched < pageLimit {
params := make(map[string]any, len(baseParams))
for key, value := range baseParams {
params[key] = value
}
params["cursor"] = cursor
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
if pagesFetched == 0 {
return nil, nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := atMeMessageItems(data)
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
continue
}
if id != "" {
seenMessages[id] = true
}
allItems = append(allItems, item)
}
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = ""
stopReason = "source_complete"
break
}
nextCursor = atMeCursorString(page["nextCursor"])
if nextCursor == "" || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := atMePayload(allItems, !rt.Bool("no-reactions"))
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
if hasMore && nextCursor != "" {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, allItems, nil
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("@我消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/search_at_me_message"),
apperrors.WithReason("at_me_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func atMeCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// atMeCompatibilityItems preserves the common list/items projection used by
// older Agent snippets while keeping messages as the canonical v1 contract.
// Its conversation field is an object so `.items[].conversation.name` is safe.
+316 -44
View File
@@ -14,7 +14,10 @@
package smart
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -36,7 +39,11 @@ const (
)
func formatDingTalkMessageBoundary(now time.Time) string {
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
localized := now.In(dingTalkMessageLocation)
if localized.Nanosecond() != 0 {
return localized.Format(time.RFC3339Nano)
}
return localized.Format("2006-01-02 15:04:05")
}
// ChatMessages resolves one conversation, projects messages into the shared
@@ -49,10 +56,10 @@ var ChatMessages = shortcut.Shortcut{
Service: "chat",
Command: "+chat-messages",
Product: "chat",
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
@@ -68,20 +75,20 @@ var ChatMessages = shortcut.Shortcut{
CLIPath: "chat +chat-messages",
PrimaryCLIPath: "chat +chat-messages",
},
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"},
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
Examples: []string{
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
@@ -93,11 +100,18 @@ var ChatMessages = shortcut.Shortcut{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名唯一解析目标会话(可选,与其他会话目标参数互斥)"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
{Name: "end", Type: shortcut.FlagString, Desc: "范围结束时间(可选、不包含);仅传开始时间时默认为当前时间"},
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(可选、不包含)"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "结果及范围遍历顺序 asc/desc(可选,默认 desc;asc 必须指定 --start/--start-time)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须指定 --start/--start-time)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
@@ -111,7 +125,17 @@ var ChatMessages = shortcut.Shortcut{
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
@@ -123,6 +147,7 @@ var ChatMessages = shortcut.Shortcut{
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --group <openconversation_id> --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
},
@@ -137,6 +162,17 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
if rt.Changed("time") && strings.TrimSpace(rt.Str("time")) != "" && !validChatTime(rt.Str("time")) {
return localChatOptionError("invalid_time_boundary", "+chat-messages 的 --time 格式无效", "--time")
}
rangeFlagsChanged := rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") ||
rt.Changed("end-time") || rt.Changed("order") || rt.Changed("sort")
if rt.Changed("time") && rangeFlagsChanged {
return apperrors.NewValidation("--time 兼容边界模式不能与 --start/--end/--order 范围模式混用")
}
if rt.Changed("direction") && rangeFlagsChanged {
return apperrors.NewValidation("--direction 兼容方向不能与 --start/--end/--order 范围模式混用")
}
if _, err := resolveChatMessageTimeRange(rt, time.Now()); err != nil {
return err
}
for _, name := range []string{"limit", "size", "page-size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
@@ -168,6 +204,107 @@ type chatMessagesRequest struct {
params map[string]any
direction string
fallbackConversationID string
timeRange chatMessageTimeRange
}
type chatMessagesSenderFilter struct {
requested bool
applied bool
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
failure map[string]any
}
// resolveOptionalChatMessagesSenderFilter is deliberately local to
// +chat-messages. Sender resolution is an optional post-read filter here: an
// unresolved name must not suppress the command's primary conversation-list
// result. +search-msg keeps its stricter semantics because sender identity is
// part of the lower search request there.
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
queries := rt.StrSlice("sender-query")
filter := chatMessagesSenderFilter{
requested: len(queries) > 0,
stableIDs: map[string]bool{},
}
if !filter.requested {
return filter
}
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
failure := map[string]any{
"stage": "sender_resolution",
"queries": queries,
"error": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
if typed.Reason != "" {
failure["reason"] = typed.Reason
}
if len(typed.Details) > 0 {
failure["details"] = typed.Details
}
}
filter.failure = failure
return filter
}
filter.resolutions = resolutions
for _, resolution := range resolutions {
for _, identity := range []string{
resolution.Selected.UserID,
resolution.Selected.OpenDingTalkID,
} {
if identity = strings.TrimSpace(identity); identity != "" {
filter.stableIDs[identity] = true
}
}
}
// ResolveUsers(IdentityAny) only returns users extracted with at least one
// stable userId/openDingTalkId and fails when no resolvable user remains.
filter.applied = true
return filter
}
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
// when optional resolution fails. Successful resolution filters both the
// public projection and raw rows so exports and resource downloads cannot
// accidentally include messages outside the requested sender set.
func applyOptionalChatMessagesSenderFilter(
rt *shortcut.RuntimeContext,
payload map[string]any,
rawItems []map[string]any,
filter chatMessagesSenderFilter,
) []map[string]any {
if !filter.requested || payload == nil {
return rawItems
}
if !filter.applied {
failures, _ := payload["failures"].([]map[string]any)
priorFailures := len(failures)
failures = append(failures, filter.failure)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(rawItems) > 0
if priorFailures == 0 {
payload["stopReason"] = "sender_resolution_failed"
}
return rawItems
}
filtered := make([]map[string]any, 0, len(rawItems))
for _, item := range rawItems {
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
filtered = append(filtered, item)
}
}
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
payload["count"] = len(filtered)
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
return filtered
}
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
@@ -194,12 +331,17 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
openID = resolved.Selected.OpenDingTalkID
}
now := time.Now()
timeRange, err := resolveChatMessageTimeRange(rt, now)
if err != nil {
return chatMessagesRequest{}, err
}
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
if direction == "" {
direction = "older"
direction = timeRange.direction()
}
params := map[string]any{
"time": formatDingTalkMessageBoundary(time.Now()),
"time": timeRange.initialBoundary(now),
"forward": direction == "newer",
}
if rt.Changed("time") && rt.Str("time") != "" {
@@ -211,7 +353,7 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
params["limit"] = chatMessagesAllPageSize
}
request := chatMessagesRequest{params: params, direction: direction}
request := chatMessagesRequest{params: params, direction: direction, timeRange: timeRange}
switch {
case groupID != "":
request.tool = "list_conversation_message_v2"
@@ -239,6 +381,19 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
} else {
payload, rawItems, err = collectOneChatMessagesPage(rt, request)
}
if err != nil && (payload == nil || payload["pagesFetched"] == 0) {
// A sender name is only an optional post-read filter. If the primary
// message read never produced a page, do not make a misleading and
// unnecessary directory request before returning the read failure.
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return err
}
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
if err != nil {
// Full-page collection returns its failure ledger together with a
// non-zero error. Publish that ledger for diagnosis, but stop before
@@ -285,10 +440,32 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
if err != nil {
return nil, nil, err
}
items := chatMessageItems(data)
rawItems := chatMessageItems(data)
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
sortMessagesByCreateTimeStable(items, request.timeRange.order)
results := projectChatMessages(items, !rt.Bool("no-reactions"))
payload := chatmsg.NewMessageListPayload(results)
chatmsg.ApplyMessagePagination(payload, data, items, request.direction)
chatmsg.ApplyMessagePagination(payload, data, rawItems, request.direction)
if metadata := request.timeRange.metadata(); metadata != nil {
payload["queryRange"] = metadata
}
if len(rangeFailures) > 0 {
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, rangeFailures...)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(items) > 0
payload["stopReason"] = "time_filter_error"
return payload, items, nil
}
if terminalReached {
payload["complete"] = true
payload["hasMore"] = false
payload["stopReason"] = request.timeRange.stopReason()
delete(payload, "nextPage")
return payload, items, nil
}
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
@@ -300,8 +477,12 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
maxResults := rt.Int("max-results")
basePageSize, _ := request.params["limit"].(int)
if basePageSize <= 0 {
basePageSize = chatMessagesAllPageSize
}
seenIDs := map[string]bool{}
seenBoundaries := map[string]bool{fmt.Sprint(request.params["time"]): true}
seenCursors := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
@@ -314,6 +495,13 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
var nextPage map[string]any
for pagesFetched < pageLimit {
request.params["limit"] = basePageSize
if maxResults > 0 {
remaining := maxResults - len(allItems)
if remaining < basePageSize {
request.params["limit"] = remaining
}
}
data, err := rt.CallMCPData("chat", request.tool, request.params)
if err != nil {
failures = append(failures, map[string]any{
@@ -325,8 +513,10 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
break
}
pagesFetched++
items := chatMessageItems(data)
keptOnPage := make([]map[string]any, 0, len(items))
rawItems := chatMessageItems(data)
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
failures = append(failures, rangeFailures...)
moreEligibleOnPage := false
for _, item := range items {
stableID := chatmsg.StableMessageID(item)
if stableID != "" && seenIDs[stableID] {
@@ -335,11 +525,27 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
if stableID != "" {
seenIDs[stableID] = true
}
allItems = append(allItems, item)
keptOnPage = append(keptOnPage, item)
if maxResults > 0 && len(allItems) >= maxResults {
break
moreEligibleOnPage = true
continue
}
allItems = append(allItems, item)
}
if len(rangeFailures) > 0 {
stopReason = "time_filter_error"
break
}
if terminalReached {
if maxResults > 0 && moreEligibleOnPage {
truncatedByResultLimit = true
hasMore = true
stopReason = "result_limit"
} else {
complete = true
hasMore = false
stopReason = request.timeRange.stopReason()
}
break
}
page := chatmsg.Pagination(data)
@@ -357,22 +563,34 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
hasMore = pageHasMore
if maxResults > 0 && len(allItems) >= maxResults {
truncatedByResultLimit = pageHasMore || len(keptOnPage) < len(items)
truncatedByResultLimit = pageHasMore || moreEligibleOnPage
if truncatedByResultLimit {
hasMore = true
stopReason = "result_limit"
if len(keptOnPage) > 0 {
nextPage = messageNextPage(keptOnPage[len(keptOnPage)-1], request.direction)
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
if boundary == "" || boundary == "<nil>" {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到结果上限但无法生成可靠的 nextPage.time",
})
stopReason = "pagination_error"
nextPage = nil
}
if moreEligibleOnPage {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到 --max-results 时当前下层页仍有未返回消息,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到结果上限但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": request.direction,
"time": boundary,
"nextCursor": page["nextCursor"],
}
break
}
@@ -383,7 +601,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "source_complete"
break
}
if len(items) == 0 {
if len(rawItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
@@ -392,18 +610,31 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "pagination_error"
break
}
nextPage = messageNextPage(items[len(items)-1], request.direction)
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
if boundary == "" || boundary == "<nil>" || seenBoundaries[boundary] {
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "hasMore=true 但 nextPage.time 缺失或停滞",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
seenBoundaries[boundary] = true
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "hasMore=true 但毫秒 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": request.direction,
"time": boundary,
"nextCursor": page["nextCursor"],
}
request.params["time"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
@@ -411,8 +642,12 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "page_limit"
}
sortMessagesByCreateTimeStable(allItems, request.timeRange.order)
results := projectChatMessages(allItems, !rt.Bool("no-reactions"))
payload := chatmsg.NewMessageListPayload(results)
if metadata := request.timeRange.metadata(); metadata != nil {
payload["queryRange"] = metadata
}
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = paginationKnown
payload["complete"] = complete && len(failures) == 0
@@ -430,6 +665,8 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
failureStage := "pagination"
if stopReason == "read_failure" {
failureStage = "read"
} else if stopReason == "time_filter_error" {
failureStage = "time_filter"
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)),
@@ -460,11 +697,46 @@ func projectChatMessages(items []map[string]any, includeReactions bool) []map[st
return results
}
func messageNextPage(message map[string]any, direction string) map[string]any {
return map[string]any{
"time": chatmsg.CreateTime(message),
"direction": direction,
// chatMessagesNextCursorBoundary converts the authoritative millisecond
// cursor returned by DingTalk message-list tools into the exact RFC3339Nano
// boundary accepted by their time parameter. Projected createTime is only
// second precision and must never drive pagination: doing so skips messages
// when a page boundary splits several messages created within the same second.
func chatMessagesNextCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
cursorKey := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return cursorKey, boundary, nil
}
// chatMessageItems defensively unwraps the message list from the response,
+334 -6
View File
@@ -18,11 +18,14 @@ import (
"context"
"encoding/json"
stderrors "errors"
"math"
"os"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -147,8 +150,9 @@ func (*chatMessagesPagingCaller) Fields() string { return "" }
func (*chatMessagesPagingCaller) JQ() string { return "" }
func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) {
const cursorMillis int64 = 1767225600123
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"},{"openMessageId":"m0","createTime":"2025-12-31 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
@@ -162,7 +166,8 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[1]["time"] != "2026-01-01 00:00:00" {
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
t.Fatalf("pagination calls = %#v", caller.args)
}
var payload map[string]any
@@ -176,9 +181,158 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
}
}
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
func TestCrossPlatformCoverageChatMessagesMillisecondCursorDoesNotSkipSameSecondRows(t *testing.T) {
const cursorMillis int64 = 1785919699136
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1785919699136,"messages":[{"openMessageId":"m4","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m3","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid", "--time", "2026-08-05 16:49:00",
"--page-size", "3", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
t.Fatalf("pagination calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(4) || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatMessagesDescendingRangeStopsAtInclusiveStart(t *testing.T) {
const cursorMillis int64 = 1767312000456
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1767312000456,"messages":[{"openMessageId":"m3","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"}]}}`,
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m0","createTime":"2026-01-01 11:59:59"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid",
"--start", "2026-01-01 12:00:00", "--end", "2026-01-04 00:00:00",
"--order", "desc", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[0]["time"] != "2026-01-04 00:00:00" ||
caller.args[0]["forward"] != false || caller.args[1]["time"] != wantBoundary {
t.Fatalf("range calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(3) || payload["stopReason"] != "range_start" {
t.Fatalf("range payload = %#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m3" ||
messages[2].(map[string]any)["messageId"] != "m1" {
t.Fatalf("descending messages = %#v", messages)
}
rangeMeta := payload["queryRange"].(map[string]any)
if rangeMeta["semantics"] != "[start,end)" || rangeMeta["order"] != "desc" {
t.Fatalf("queryRange = %#v", rangeMeta)
}
}
func TestCrossPlatformCoverageChatMessagesAscendingRangeStopsAtExclusiveEnd(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m-end","createTime":"2026-01-03 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid",
"--start-time", "2026-01-01 12:00:00", "--end-time", "2026-01-03 00:00:00",
"--sort", "asc", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 1 || caller.args[0]["time"] != "2026-01-01 12:00:00" ||
caller.args[0]["forward"] != true {
t.Fatalf("ascending call = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(2) || payload["stopReason"] != "range_end" {
t.Fatalf("ascending payload = %#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m1" ||
messages[1].(map[string]any)["messageId"] != "m2" {
t.Fatalf("ascending messages = %#v", messages)
}
}
func TestCrossPlatformCoverageChatMessagesFirstReadFailureSkipsOptionalSenderResolution(t *testing.T) {
caller := &chatMessagesPagingCaller{
responses: []string{`{"result":[]}`},
failAt: 1,
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid", "--page-all",
"--sender-query", "张三",
})
if err := root.Execute(); err == nil {
t.Fatal("first-page failure unexpectedly succeeded")
}
if len(caller.args) != 1 {
t.Fatalf("calls after first read failure = %#v, want only the message read", caller.args)
}
}
func TestCrossPlatformCoverageChatMessagesRangeValidationStopsBeforeRead(t *testing.T) {
for _, args := range [][]string{
{"--start", "2026-01-02", "--end", "2026-01-01"},
{"--end", "2026-01-02", "--order", "asc"},
{"--time", "2026-01-02", "--start", "2026-01-01"},
{"--direction", "newer", "--start", "2026-01-01"},
{"--start", "2026-01-01", "--start-time", "2026-01-01"},
} {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-messages", "--conversation-id", "cid"}, args...))
if err := root.Execute(); err == nil {
t.Errorf("invalid range succeeded: %v", args)
}
if len(caller.args) != 0 {
t.Errorf("invalid range reached message read: %v calls=%#v", args, caller.args)
}
}
}
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
const cursorMillis int64 = 1767225600123
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
@@ -196,9 +350,10 @@ func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t
t.Fatal(err)
}
next, _ := payload["nextPage"].(map[string]any)
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
next["time"] != "2026-01-01 00:00:00" {
next["time"] != wantBoundary || next["nextCursor"] != float64(cursorMillis) {
t.Fatalf("bounded payload = %#v", payload)
}
}
@@ -233,7 +388,7 @@ func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t
t.Chdir(t.TempDir())
caller := &chatMessagesPagingCaller{
responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
},
failAt: 2,
}
@@ -332,3 +487,176 @@ func TestCrossPlatformCoverageChatMessagesExportRejectsNonJSONPlaceholder(t *tes
t.Fatalf("placeholder output was created: %v", err)
}
}
func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
if err != nil {
t.Fatal(err)
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
}
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
for _, values := range []map[string]string{
{"time": "2026-01-01", "start": "2026-01-01"},
{"direction": "older", "start": "2026-01-01"},
} {
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
t.Fatalf("direct validation unexpectedly accepted %#v", values)
}
}
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
"conversation-id": "cid", "start": "not-a-time",
})); err == nil {
t.Fatal("invalid direct request range unexpectedly succeeded")
}
helpers.InitDeps(&platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲"}]}`,
})
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender-query": "测试用户甲",
}))
if filter.applied || filter.failure == nil {
t.Fatalf("identity-free sender resolution = %#v", filter)
}
for _, tc := range []struct {
name string
value any
wantError bool
}{
{name: "int", value: int(1234)},
{name: "int32", value: int32(1234)},
{name: "int64", value: int64(1234)},
{name: "float32", value: float32(1024)},
{name: "float64", value: float64(1234)},
{name: "string", value: "1234"},
{name: "float32 nan", value: float32(math.NaN()), wantError: true},
{name: "float32 infinity", value: float32(math.Inf(1)), wantError: true},
{name: "float64 fractional", value: 1.5, wantError: true},
{name: "bad string", value: "opaque", wantError: true},
{name: "missing", value: nil, wantError: true},
{name: "zero", value: 0, wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, err := chatMessagesNextCursorBoundary(tc.value)
if (err != nil) != tc.wantError {
t.Fatalf("value=%#v err=%v wantError=%v", tc.value, err, tc.wantError)
}
})
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDeps(caller)
return chatMessagesRuntimeForTest(t, values)
}
start := time.Date(2026, 1, 2, 0, 0, 0, 0, dingTalkMessageLocation)
configuredRange := chatMessageTimeRange{configured: true, start: &start, order: "desc"}
t.Run("single-page range failures and terminal", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
}}
payload, _, err := collectOneChatMessagesPage(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["stopReason"] != "time_filter_error" || payload["queryRange"] == nil {
t.Fatalf("payload=%#v err=%v", payload, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err = collectOneChatMessagesPage(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["complete"] != true || payload["stopReason"] != "range_start" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("default page size and time-filter failure", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err != nil || payload["complete"] != true || caller.args[0]["limit"] != chatMessagesAllPageSize {
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
}}
payload, _, err = collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err == nil || payload["stopReason"] != "time_filter_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
}}
payload, _, err = collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("stalled cursor", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m2"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "pagination_error" || len(caller.args) != 2 {
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
}
})
t.Run("first failure ledger output error", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
}
@@ -37,11 +37,13 @@ type platformCoverageCall struct {
}
type platformCoverageCaller struct {
calls []platformCoverageCall
dry bool
contactSearchResult string
aisearchResult string
failTool string
calls []platformCoverageCall
dry bool
contactSearchResult string
aisearchResult string
chatMessagesResult string
searchMessagesResult string
failTool string
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -65,6 +67,14 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
text = `{"result":{"userId":"u1"}}`
case "im/search_groups":
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
case "chat/list_conversation_message_v2":
if f.chatMessagesResult != "" {
text = f.chatMessagesResult
}
case "im/search_messages":
if f.searchMessagesResult != "" {
text = f.searchMessagesResult
}
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -153,6 +163,8 @@ func TestCrossPlatformCoverageIMObservedCompatibilityAliasesConflictWithCanonica
for _, args := range [][]string{
{"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"},
{"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"},
{"chat", "+search-msg", "--query", "评测", "--start", "2026-01-01T00:00:00+08:00", "--start-time", "2026-01-01T00:00:00+08:00", "--end", "2026-01-02T00:00:00+08:00", "--no-enrich"},
{"chat", "+search-msg", "--query", "评测", "--order", "asc", "--sort", "asc", "--no-enrich"},
} {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
@@ -296,7 +308,7 @@ func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *t
dry: true,
aisearchResult: `{"result":[{
"userId":"user-alias",
"meta":{"name":"柏荣","nick":"大柚"}
"meta":{"name":"测试用户甲","nick":"测试花名甲"}
}]}`,
}
helpers.InitDeps(fake)
@@ -458,11 +458,11 @@ func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *test
}{
{name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true},
{name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true},
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{
@@ -6,7 +6,6 @@ package smart
import (
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -19,13 +18,8 @@ func defaultChatPageLimit(value, fallback int) int {
}
func validChatTime(value string) bool {
value = strings.TrimSpace(value)
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
if _, err := time.Parse(layout, value); err == nil {
return true
}
}
return false
_, err := parseDingTalkMessageTime(strings.TrimSpace(value))
return err == nil
}
func localChatOptionError(reason, message string, flags ...string) error {
@@ -0,0 +1,246 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package smart
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const messageTimeRangeSemantics = "[start,end)"
type chatMessageTimeRange struct {
configured bool
start *time.Time
end *time.Time
order string
}
func resolveChatMessageTimeRange(rt *shortcut.RuntimeContext, now time.Time) (chatMessageTimeRange, error) {
startValue := strings.TrimSpace(rt.StrFirst("start", "start-time"))
endValue := strings.TrimSpace(rt.StrFirst("end", "end-time"))
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
configured := startValue != "" || endValue != "" || order != ""
if order == "" {
order = "desc"
}
result := chatMessageTimeRange{configured: configured, order: order}
if startValue != "" {
parsed, err := parseDingTalkMessageTime(startValue)
if err != nil {
return chatMessageTimeRange{}, localChatOptionError(
"invalid_start_time", "+chat-messages 的开始时间格式无效", "--start/--start-time")
}
result.start = &parsed
}
if endValue != "" {
parsed, err := parseDingTalkMessageTime(endValue)
if err != nil {
return chatMessageTimeRange{}, localChatOptionError(
"invalid_end_time", "+chat-messages 的结束时间格式无效", "--end/--end-time")
}
result.end = &parsed
}
if result.start != nil && result.end != nil && !result.end.After(*result.start) {
return chatMessageTimeRange{}, apperrors.NewValidation("--end/--end-time 必须晚于 --start/--start-time")
}
if order == "asc" && result.start == nil {
return chatMessageTimeRange{}, apperrors.NewValidation("升序读取必须指定 --start/--start-time,避免把最近一页倒序后误报为最早消息")
}
if result.start != nil && result.end == nil {
effectiveEnd := now
result.end = &effectiveEnd
}
return result, nil
}
func (r chatMessageTimeRange) initialBoundary(now time.Time) string {
if !r.configured {
return formatDingTalkMessageBoundary(now.Truncate(time.Second))
}
if r.order == "asc" && r.start != nil {
return formatDingTalkMessageBoundary(*r.start)
}
if r.end != nil {
return formatDingTalkMessageBoundary(*r.end)
}
return formatDingTalkMessageBoundary(now)
}
func (r chatMessageTimeRange) direction() string {
if r.order == "asc" {
return "newer"
}
return "older"
}
func (r chatMessageTimeRange) stopReason() string {
if r.order == "asc" {
return "range_end"
}
return "range_start"
}
func (r chatMessageTimeRange) metadata() map[string]any {
if !r.configured {
return nil
}
result := map[string]any{
"order": r.order,
"semantics": messageTimeRangeSemantics,
}
if r.start != nil {
result["startTime"] = r.start.Format(time.RFC3339Nano)
}
if r.end != nil {
result["endTime"] = r.end.Format(time.RFC3339Nano)
}
return result
}
func (r chatMessageTimeRange) filter(items []map[string]any) ([]map[string]any, bool, []map[string]any) {
if r.start == nil && r.end == nil {
return items, false, nil
}
filtered := make([]map[string]any, 0, len(items))
unknown := make([]string, 0)
terminalReached := false
for _, item := range items {
messageTime, ok := parseMessageTimestamp(chatmsg.CreateTime(item))
if !ok {
unknown = append(unknown, chatmsg.StableMessageID(item))
continue
}
if r.start != nil && messageTime.Before(*r.start) {
if r.order == "desc" {
terminalReached = true
}
continue
}
if r.end != nil && !messageTime.Before(*r.end) {
if r.order == "asc" {
terminalReached = true
}
continue
}
filtered = append(filtered, item)
}
if len(unknown) == 0 {
return filtered, terminalReached, nil
}
return filtered, terminalReached, []map[string]any{{
"stage": "time_filter",
"messageIds": unknown,
"error": "消息缺少可解析的 createTime,无法证明时间范围结果完整",
}}
}
func parseDingTalkMessageTime(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, value, dingTalkMessageLocation); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported message time %q", value)
}
func parseMessageTimestamp(value any) (time.Time, bool) {
switch typed := value.(type) {
case string:
trimmed := strings.TrimSpace(typed)
if parsed, err := parseDingTalkMessageTime(trimmed); err == nil {
return parsed, true
}
if numeric, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
return unixMessageTime(numeric), true
}
case json.Number:
if numeric, err := typed.Int64(); err == nil {
return unixMessageTime(numeric), true
}
case float64:
return unixMessageTime(int64(typed)), true
case float32:
return unixMessageTime(int64(typed)), true
case int:
return unixMessageTime(int64(typed)), true
case int64:
return unixMessageTime(typed), true
case int32:
return unixMessageTime(int64(typed)), true
}
return time.Time{}, false
}
func unixMessageTime(value int64) time.Time {
if value > -1_000_000_000_000 && value < 1_000_000_000_000 {
return time.Unix(value, 0)
}
return time.UnixMilli(value)
}
func sortMessagesByCreateTimeStable(messages []map[string]any, order string) {
order = strings.ToLower(strings.TrimSpace(order))
if order != "asc" && order != "desc" {
return
}
sort.SliceStable(messages, func(i, j int) bool {
left, leftOK := parseMessageTimestamp(chatmsg.CreateTime(messages[i]))
right, rightOK := parseMessageTimestamp(chatmsg.CreateTime(messages[j]))
if leftOK != rightOK {
return leftOK
}
if !leftOK || left.Equal(right) {
return false
}
if order == "asc" {
return left.Before(right)
}
return left.After(right)
})
}
func searchMessageQueryRange(params map[string]any, order string) map[string]any {
result := map[string]any{
"order": order,
"semantics": messageTimeRangeSemantics,
}
if start, ok := numericMillis(params["startTime"]); ok {
result["startTime"] = time.UnixMilli(start).In(dingTalkMessageLocation).Format(time.RFC3339)
}
if end, ok := numericMillis(params["endTime"]); ok {
result["endTime"] = time.UnixMilli(end).In(dingTalkMessageLocation).Format(time.RFC3339)
}
return result
}
func numericMillis(value any) (int64, bool) {
switch typed := value.(type) {
case int64:
return typed, true
case int:
return int64(typed), true
case float64:
return int64(typed), true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,242 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package smart
import (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func messageTimeRangeRuntime(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
cmd.Flags().String(name, "", "")
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return shortcut.RuntimeContextForTest(cmd, shortcut.Shortcut{})
}
func TestCrossPlatformCoverageResolveMessageTimeRange(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, dingTalkMessageLocation)
plain, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, nil), now)
if err != nil || plain.configured || plain.order != "desc" || plain.start != nil || plain.end != nil {
t.Fatalf("plain range = %#v, %v", plain, err)
}
if plain.metadata() != nil || plain.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("plain metadata/boundary = %#v, %q", plain.metadata(), plain.initialBoundary(now))
}
orderOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"order": "desc",
}), now)
if err != nil || !orderOnly.configured || orderOnly.start != nil || orderOnly.end != nil ||
orderOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("order-only range = %#v, %v", orderOnly, err)
}
ascending, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start-time": "2026-08-01",
"end-time": "2026-08-03 00:00:00",
"sort": " ASC ",
}), now)
if err != nil {
t.Fatal(err)
}
if ascending.direction() != "newer" || ascending.stopReason() != "range_end" {
t.Fatalf("ascending direction/reason = %q, %q", ascending.direction(), ascending.stopReason())
}
if ascending.initialBoundary(now) != formatDingTalkMessageBoundary(*ascending.start) {
t.Fatalf("ascending boundary = %q", ascending.initialBoundary(now))
}
metadata := ascending.metadata()
if metadata["order"] != "asc" || metadata["semantics"] != messageTimeRangeSemantics || metadata["startTime"] == nil || metadata["endTime"] == nil {
t.Fatalf("ascending metadata = %#v", metadata)
}
startOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start": "2026-08-01T00:00:00+08:00",
}), now)
if err != nil {
t.Fatal(err)
}
if startOnly.end == nil || !startOnly.end.Equal(now) || startOnly.direction() != "older" || startOnly.stopReason() != "range_start" {
t.Fatalf("start-only range = %#v", startOnly)
}
if startOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("start-only boundary = %q", startOnly.initialBoundary(now))
}
fractional, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start": "2026-08-02T00:00:00.125+08:00",
"end": "2026-08-03T00:00:00.500+08:00",
}), now)
if err != nil {
t.Fatal(err)
}
if got := fractional.initialBoundary(now); got != "2026-08-03T00:00:00.5+08:00" {
t.Fatalf("fractional boundary = %q", got)
}
fractionalMetadata := fractional.metadata()
if fractionalMetadata["startTime"] != "2026-08-02T00:00:00.125+08:00" ||
fractionalMetadata["endTime"] != "2026-08-03T00:00:00.5+08:00" {
t.Fatalf("fractional metadata = %#v", fractionalMetadata)
}
for name, values := range map[string]map[string]string{
"invalid start": {"start": "not-a-time"},
"invalid end": {"end": "not-a-time"},
"reversed": {"start": "2026-08-03", "end": "2026-08-02"},
"asc no start": {"end": "2026-08-03", "order": "asc"},
} {
t.Run(name, func(t *testing.T) {
if _, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, values), now); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestCrossPlatformCoverageMessageTimeRangeParsesLocalAndEpochTimes(t *testing.T) {
local, err := parseDingTalkMessageTime("2026-01-02 03:04:05")
if err != nil {
t.Fatal(err)
}
if local.Location().String() != "CST" || local.Format(time.RFC3339) != "2026-01-02T03:04:05+08:00" {
t.Fatalf("local time = %s (%s)", local.Format(time.RFC3339), local.Location())
}
for _, value := range []any{
local.Unix(), int(local.Unix()), int32(local.Unix()),
local.UnixMilli(), float64(local.UnixMilli()), json.Number("1767294245000"), "1767294245000",
} {
parsed, ok := parseMessageTimestamp(value)
if !ok || parsed.Unix() != local.Unix() {
t.Fatalf("parse %v = %s, %v", value, parsed, ok)
}
}
float32Seconds := float32(local.Unix())
if parsed, ok := parseMessageTimestamp(float32Seconds); !ok || parsed.Unix() != int64(float32Seconds) {
t.Fatalf("parse float32 %v = %s, %v", float32Seconds, parsed, ok)
}
if parsed, err := parseDingTalkMessageTime("2026-01-02T03:04:05+08:00"); err != nil || parsed.Unix() != local.Unix() {
t.Fatalf("RFC3339 parse = %s, %v", parsed, err)
}
if _, err := parseDingTalkMessageTime("unsupported"); err == nil {
t.Fatal("unsupported time unexpectedly parsed")
}
for _, value := range []any{json.Number("bad"), "bad", true, nil} {
if parsed, ok := parseMessageTimestamp(value); ok || !parsed.IsZero() {
t.Fatalf("invalid timestamp %v = %s, %v", value, parsed, ok)
}
}
}
func TestCrossPlatformCoverageMessageTimeRangeStableSortKeepsUnknownRows(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "unknown"},
{"openMessageId": "later", "createTime": "2026-01-02 00:00:00"},
{"openMessageId": "same-a", "createTime": "2026-01-01 00:00:00"},
{"openMessageId": "same-b", "createTime": "2026-01-01 00:00:00"},
}
sortMessagesByCreateTimeStable(messages, "asc")
want := []string{"same-a", "same-b", "later", "unknown"}
for index, id := range want {
if messages[index]["openMessageId"] != id {
t.Fatalf("sorted messages = %#v", messages)
}
}
sortMessagesByCreateTimeStable(messages, "desc")
if messages[0]["openMessageId"] != "later" || messages[len(messages)-1]["openMessageId"] != "unknown" {
t.Fatalf("descending messages = %#v", messages)
}
before := append([]map[string]any(nil), messages...)
sortMessagesByCreateTimeStable(messages, "sideways")
for index := range before {
if before[index]["openMessageId"] != messages[index]["openMessageId"] {
t.Fatalf("invalid-order sort changed messages = %#v", messages)
}
}
}
func TestCrossPlatformCoverageMessageTimeRangeFiltering(t *testing.T) {
start, _ := parseDingTalkMessageTime("2026-08-02 00:00:00")
end, _ := parseDingTalkMessageTime("2026-08-04 00:00:00")
items := []map[string]any{
{"openMessageId": "before", "createTime": "2026-08-01 00:00:00"},
{"openMessageId": "inside", "createTime": "2026-08-03 00:00:00"},
{"openMessageId": "end", "createTime": "2026-08-04 00:00:00"},
{"openMessageId": "unknown"},
}
plain := chatMessageTimeRange{}
plainItems, terminal, failures := plain.filter(items)
if len(plainItems) != len(items) || terminal || failures != nil {
t.Fatalf("plain filter = %#v, %v, %#v", plainItems, terminal, failures)
}
desc := chatMessageTimeRange{start: &start, end: &end, order: "desc"}
filtered, terminal, failures := desc.filter(items)
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || len(failures) != 1 {
t.Fatalf("descending filter = %#v, %v, %#v", filtered, terminal, failures)
}
asc := chatMessageTimeRange{start: &start, end: &end, order: "asc"}
filtered, terminal, failures = asc.filter(items[1:3])
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || failures != nil {
t.Fatalf("ascending filter = %#v, %v, %#v", filtered, terminal, failures)
}
filtered, terminal, failures = desc.filter(items[1:2])
if len(filtered) != 1 || terminal || failures != nil {
t.Fatalf("clean filter = %#v, %v, %#v", filtered, terminal, failures)
}
fractionalStart, _ := parseDingTalkMessageTime("2026-08-03T00:00:00+08:00")
fractionalEnd, _ := parseDingTalkMessageTime("2026-08-03T00:00:00.500+08:00")
fractionalRange := chatMessageTimeRange{start: &fractionalStart, end: &fractionalEnd, order: "desc"}
fractionalItems := []map[string]any{
{"openMessageId": "inside-fraction", "createTime": "2026-08-03T00:00:00.250+08:00"},
{"openMessageId": "end-fraction", "createTime": "2026-08-03T00:00:00.500+08:00"},
}
filtered, terminal, failures = fractionalRange.filter(fractionalItems)
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside-fraction" || terminal || failures != nil {
t.Fatalf("fractional filter = %#v, %v, %#v", filtered, terminal, failures)
}
}
func TestCrossPlatformCoverageSearchMessageQueryRangeNumericInputs(t *testing.T) {
start := time.Date(2026, 8, 1, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
end := time.Date(2026, 8, 2, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
queryRange := searchMessageQueryRange(map[string]any{
"startTime": int(start),
"endTime": end,
}, "asc")
if queryRange["order"] != "asc" || queryRange["semantics"] != messageTimeRangeSemantics || queryRange["startTime"] == nil || queryRange["endTime"] == nil {
t.Fatalf("query range = %#v", queryRange)
}
for _, value := range []any{int64(start), int(start), float64(start), json.Number("1785513600000")} {
if parsed, ok := numericMillis(value); !ok || parsed != start {
t.Fatalf("numericMillis(%T) = %d, %v", value, parsed, ok)
}
}
for _, value := range []any{json.Number("bad"), "1785513600000", nil} {
if parsed, ok := numericMillis(value); ok || parsed != 0 {
t.Fatalf("invalid numericMillis(%T) = %d, %v", value, parsed, ok)
}
}
queryRange = searchMessageQueryRange(map[string]any{"startTime": "bad", "endTime": true}, "desc")
if len(queryRange) != 2 {
t.Fatalf("invalid numeric query range = %#v", queryRange)
}
}
+192 -29
View File
@@ -14,16 +14,23 @@
package smart
import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const (
myGroupsDefaultPageLimit = 50
myGroupsHardPageLimit = 500
)
// MyGroups: list the groups I've joined and project just the key fields
// (会话id / 名称 / 群主 / 人数 / 类型) into a clean, composed payload — instead of
// paging through `chat group list-all` and squinting at the raw MCP response.
@@ -79,48 +86,204 @@ var MyGroups = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200)", Default: "200"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +my-groups`,
`dws chat +my-groups --type group`,
`dws chat +my-groups --page-all --page-limit 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
// call site (chat group list-all); pass a generous page size.
params := map[string]any{"limit": rt.Int("limit")}
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
params["cursor"] = cursor
Validate: validateMyGroups,
Execute: executeMyGroups,
}
func validateMyGroups(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
return apperrors.NewValidation("--limit 必须在 1-200 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > myGroupsHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func executeMyGroups(rt *shortcut.RuntimeContext) error {
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
// call site (chat group list-all); pass a generous page size.
params := map[string]any{"limit": rt.Int("limit")}
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
params["cursor"] = cursor
}
if rt.Bool("page-all") {
payload, err := readAllMyGroups(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
payload := myGroupsPayload(rt, myGroupsExtract(data))
chatmsg.ApplyPagination(payload, data)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func myGroupsPayload(rt *shortcut.RuntimeContext, groups []map[string]any) map[string]any {
typeFilter := strings.TrimSpace(rt.Str("type"))
projected := make([]map[string]any, 0, len(groups))
for _, group := range groups {
row := myGroupsProject(group)
if typeFilter != "" {
groupType, _ := row["type"].(string)
if !strings.EqualFold(strings.TrimSpace(groupType), typeFilter) {
continue
}
}
projected = append(projected, row)
}
return map[string]any{"count": len(projected), "groups": projected}
}
func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), myGroupsDefaultPageLimit)
cursorValue := baseParams["cursor"]
cursorKey := myGroupsCursorString(cursorValue)
if cursorKey == "" {
cursorKey = "0"
}
seenCursors := map[string]bool{cursorKey: true}
seenGroups := map[string]bool{}
allGroups := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
// Step 2 — project key fields defensively.
groups := myGroupsExtract(data)
typeFilter := strings.TrimSpace(rt.Str("type"))
projected := make([]map[string]any, 0, len(groups))
for _, g := range groups {
row := myGroupsProject(g)
if typeFilter != "" {
gt, _ := row["type"].(string)
if !strings.EqualFold(strings.TrimSpace(gt), typeFilter) {
continue
}
if pagesFetched == 0 {
return nil, err
}
projected = append(projected, row)
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageGroups := myGroupsExtract(data)
for _, group := range pageGroups {
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
if id != "" && seenGroups[id] {
continue
}
if id != "" {
seenGroups[id] = true
}
allGroups = append(allGroups, group)
}
payload := map[string]any{
"count": len(projected),
"groups": projected,
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = nil
stopReason = "source_complete"
break
}
nextCursor = page["nextCursor"]
nextKey := myGroupsCursorString(nextCursor)
if nextKey == "" || seenCursors[nextKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := myGroupsPayload(rt, allGroups)
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("我的群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_my_groups_pagination"),
apperrors.WithReason("my_groups_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func myGroupsCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// myGroupsExtract walks a list_my_groups_pagination response and returns its
@@ -5,13 +5,82 @@
package smart
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *testing.T) {
tests := []struct {
name string
flags []shortcut.Flag
want []string
}{
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, name := range tc.want {
found := false
for _, flag := range tc.flags {
if flag.Name != name {
continue
}
found = true
if flag.Hidden {
t.Fatalf("--%s unexpectedly became hidden", name)
}
if flag.Required {
t.Fatalf("--%s unexpectedly became required", name)
}
break
}
if !found {
t.Fatalf("public optional flag --%s is missing", name)
}
}
})
}
}
func TestCrossPlatformCoverageMessageTimeAlignmentFlagsStayPublicOptional(t *testing.T) {
for _, tc := range []struct {
name string
flags []shortcut.Flag
}{
{name: "search message", flags: SearchMsg.Flags},
{name: "conversation message", flags: ChatMessages.Flags},
} {
t.Run(tc.name, func(t *testing.T) {
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
found := false
for _, flag := range tc.flags {
if flag.Name != name {
continue
}
found = true
if flag.Hidden || flag.Required {
t.Fatalf("--%s must stay public and optional: %#v", name, flag)
}
if (name == "order" || name == "sort") && !reflect.DeepEqual(flag.Enum, []string{"asc", "desc"}) {
t.Fatalf("--%s enum = %#v", name, flag.Enum)
}
break
}
if !found {
t.Fatalf("public optional flag --%s is missing", name)
}
}
})
}
}
func TestCrossPlatformCoverageChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) {
tests := []struct {
name string
@@ -76,6 +145,103 @@ func TestCrossPlatformCoverageChatMessagesStableGroupBypassesNaturalResolution(t
}
}
func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEitherStableID(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m-open","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"open id match"},
{"openMessageId":"m-user","sender":"群昵称甲","senderUserId":"uid-fixture-sender-1","content":"user id match"},
{"openMessageId":"m-other","sender":"其他人","senderOpenDingTalkId":"D2","content":"other"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "测试用户甲", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by optional sender resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["complete"] != true || payload["count"] != float64(2) {
t.Fatalf("filtered payload = %#v", payload)
}
filters, ok := payload["resolvedFilters"].(map[string]any)
if !ok {
t.Fatalf("resolvedFilters missing: %#v", payload)
}
senders, ok := filters["senders"].([]any)
if !ok || len(senders) != 1 {
t.Fatalf("resolved senders = %#v", filters["senders"])
}
selected := senders[0].(map[string]any)["selected"].(map[string]any)
if selected["userId"] != "uid-fixture-sender-1" || selected["openDingTalkId"] != "D1" {
t.Fatalf("selected identity = %#v", selected)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", payload["messages"])
}
for _, raw := range messages {
message := raw.(map[string]any)
if message["sender"] != "群昵称甲" || message["senderId"] == "D2" {
t.Fatalf("unexpected filtered message = %#v", message)
}
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"仍然返回"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "不存在的人", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatalf("optional sender failure stopped message read: %v", err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["count"] != float64(1) || payload["complete"] != false ||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "sender_resolution_failed" {
t.Fatalf("unfiltered fallback payload = %#v", payload)
}
if _, exists := payload["resolvedFilters"]; exists {
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
}
failures, ok := payload["failures"].([]any)
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "sender_resolution" {
t.Fatalf("resolution failures = %#v", payload["failures"])
}
}
func TestCrossPlatformCoverageChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) {
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`}
helpers.InitDeps(fake)
@@ -172,6 +338,47 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(
}
}
func TestCrossPlatformCoverageSearchMsgPreservesResolvedSenderWhenDisplayNameDiffers(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
searchMessagesResult: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"评测消息"}],"hasMore":false}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+search-msg", "--sender-query", "测试用户甲", "--no-enrich"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
filters, ok := payload["resolvedFilters"].(map[string]any)
if !ok {
t.Fatalf("resolvedFilters missing: %#v", payload)
}
senders, ok := filters["senders"].([]any)
if !ok || len(senders) != 1 {
t.Fatalf("resolved senders = %#v", filters["senders"])
}
resolution := senders[0].(map[string]any)
selected := resolution["selected"].(map[string]any)
if resolution["query"] != "测试用户甲" || selected["openDingTalkId"] != "D1" {
t.Fatalf("sender resolution = %#v", resolution)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v", payload["messages"])
}
message := messages[0].(map[string]any)
if message["sender"] != "群昵称甲" || message["senderId"] != "D1" {
t.Fatalf("projected message = %#v", message)
}
}
func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
@@ -0,0 +1,306 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageAtMePageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m2"},{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m1"},{"openMessageId":"m0"}]}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--limit", "2", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[0]["cursor"] != "0" || caller.args[1]["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
if len(payload["items"].([]any)) != 3 {
t.Fatalf("compatibility items = %#v", payload["items"])
}
}
func TestCrossPlatformCoverageAtMePageAllFailsClosedWithoutContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("missing @me continuation unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群","groupType":"group"},{"openConversationId":"g2","title":"单聊","groupType":"p2p"}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[1]["cursor"] != float64(88) {
t.Fatalf("calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+at-me", "--page-limit", "2"},
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
{"chat", "+my-groups", "--limit", "201"},
{"chat", "+my-groups", "--page-limit", "2"},
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&chatMessagesPagingCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageMyGroupsAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+my-groups"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single-page outcomes", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[],"hasMore":false}}`,
}}, "--cursor", "cursor-1")
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
payload, err = run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
}})
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if _, err = run(t, &chatMessagesPagingCaller{failAt: 1}); err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("all-page failures", func(t *testing.T) {
if _, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all"); err == nil {
t.Fatal("first-page failure unexpectedly succeeded")
}
payload, err := run(t, &chatMessagesPagingCaller{
responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`},
failAt: 2,
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
args []string
stop string
wantErr bool
}{
{name: "unknown pagination", responses: []string{`{"result":{"groups":[]}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "missing continuation", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "stalled continuation", responses: []string{
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"groups":[{"openConversationId":"g2"}],"hasMore":true,"nextCursor":2}}`,
}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "page limit", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output failures", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{`{"result":{"groups":[],"hasMore":false}}`}})
for _, args := range [][]string{{"chat", "+my-groups"}, {"chat", "+my-groups", "--page-all"}} {
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("output failure swallowed for %v", args)
}
}
})
for _, value := range []any{nil, "", "0", "<nil>", 0} {
if got := myGroupsCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
}
func TestCrossPlatformCoverageAtMeAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+at-me"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single page terminal and read failure", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
}})
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
_, err = run(t, &chatMessagesPagingCaller{failAt: 1})
if err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("all-page read failures", func(t *testing.T) {
_, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all")
if err == nil {
t.Fatal("first all-page read failure unexpectedly succeeded")
}
payload, err := run(t, &chatMessagesPagingCaller{
responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`},
failAt: 2,
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
args []string
wantErr bool
stop string
}{
{name: "unknown pagination", responses: []string{`{"result":{"conversationMessagesList":[]}}`}, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "page limit", responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
{name: "empty cursor defaults", responses: []string{`{"result":{"conversationMessagesList":[],"hasMore":false}}`}, args: []string{"--cursor=", "--page-all"}, stop: "source_complete"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if tc.stop == "page_limit" && payload["nextCursor"] != "next" {
t.Fatalf("continuation payload=%#v", payload)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
}})
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+at-me"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
for _, value := range []any{nil, "", "0", "<nil>"} {
if got := atMeCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
}
+57 -27
View File
@@ -38,8 +38,8 @@ var SearchMsg = shortcut.Shortcut{
Service: "chat",
Command: "+search-msg",
Product: "im",
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" +
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
@@ -55,16 +55,16 @@ var SearchMsg = shortcut.Shortcut{
CLIPath: "chat +search-msg",
PrimaryCLIPath: "chat +search-msg",
},
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
AgentSummary: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads",
@@ -81,10 +81,10 @@ var SearchMsg = shortcut.Shortcut{
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名解析会话的兼容入口", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名解析发送者的兼容入口", Hidden: true},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
@@ -93,8 +93,12 @@ var SearchMsg = shortcut.Shortcut{
{Name: "conversation-type", Type: shortcut.FlagString, Desc: "下层会话类型过滤值(以当前 IM Schema 为准)"},
{Name: "chat-type", Type: shortcut.FlagString, Desc: "--conversation-type 的 lark-cli 对齐别名"},
{Name: "days", Type: shortcut.FlagInt, Desc: "默认时间窗的回溯天数", Default: "7"},
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end 一起传)"},
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start 一起传)"},
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end/--end-time 一起传)"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(RFC3339,需与 --end/--end-time 一起传)"},
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start/--start-time 一起传)"},
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(RFC3339,需与 --start/--start-time 一起传)"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "按消息创建时间稳定排列输出 asc/desc(可选,默认 desc)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(1-100)", Default: "100"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 lark-cli 对齐别名(1-100)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
@@ -112,14 +116,17 @@ var SearchMsg = shortcut.Shortcut{
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start"},
Description: "需与 --end 一起传",
Flags: []string{"start", "start-time"},
Description: "需与 --end/--end-time 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
Flags: []string{"end", "end-time"},
Description: "需与 --start/--start-time 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}},
@@ -134,7 +141,7 @@ var SearchMsg = shortcut.Shortcut{
},
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
params, resolvedFilters, err := searchMsgParams(rt)
if err != nil {
return err
}
@@ -229,6 +236,11 @@ var SearchMsg = shortcut.Shortcut{
}
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
order = "desc"
}
sortMessagesByCreateTimeStable(messages, order)
results := make([]map[string]any, 0, len(messages))
for _, m := range messages {
results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions")))
@@ -245,6 +257,10 @@ var SearchMsg = shortcut.Shortcut{
"paginationKnown": paginationKnown,
"failedCount": len(failures),
"failures": failures,
"queryRange": searchMessageQueryRange(params, order),
}
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
@@ -281,8 +297,10 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
if !hasFilter {
return apperrors.NewValidation("至少指定一个过滤条件,例如 --query、--group、--senders、--at-me 或 --message-type")
}
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时指定")
startChanged := rt.Changed("start") || rt.Changed("start-time")
endChanged := rt.Changed("end") || rt.Changed("end-time")
if startChanged != endChanged {
return apperrors.NewValidation("--start/--start-time 与 --end/--end-time 必须同时指定")
}
if days := rt.Int("days"); days < 1 || days > 3650 {
return apperrors.NewValidation("--days 必须在 1-3650 之间")
@@ -297,8 +315,17 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
return nil
}
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
// searchResolvedFilters preserves the natural sender facts applied to the
// lower search. Message sender names are display labels and may differ from
// the directory name, so consumers must join the selected stable identity to
// each projected message's senderId instead of comparing those two names.
type searchResolvedFilters struct {
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
}
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolvedFilters, error) {
params := map[string]any{"limit": rt.IntFirst("limit", "page-size")}
resolvedFilters := searchResolvedFilters{}
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
params["keyword"] = value
}
@@ -311,7 +338,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
for _, query := range queries {
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
if err != nil {
return nil, err
return nil, searchResolvedFilters{}, err
}
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
@@ -324,8 +351,9 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
return nil, err
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
for _, resolved := range resolvedUsers {
identity := resolved.Selected.OpenDingTalkID
if identity == "" {
@@ -349,17 +377,19 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
params["searchConvType"] = value
}
if rt.Changed("start") && rt.Changed("end") {
start, err := time.Parse(time.RFC3339, rt.Str("start"))
startValue := rt.StrFirst("start", "start-time")
endValue := rt.StrFirst("end", "end-time")
if startValue != "" && endValue != "" {
start, err := time.Parse(time.RFC3339, startValue)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--start 必须是 RFC3339 时间: %v", err))
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--start/--start-time 必须是 RFC3339 时间: %v", err))
}
end, err := time.Parse(time.RFC3339, rt.Str("end"))
end, err := time.Parse(time.RFC3339, endValue)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--end 必须是 RFC3339 时间: %v", err))
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--end/--end-time 必须是 RFC3339 时间: %v", err))
}
if !end.After(start) {
return nil, apperrors.NewValidation("--end 必须晚于 --start")
return nil, searchResolvedFilters{}, apperrors.NewValidation("--end 必须晚于 --start")
}
params["startTime"] = start.UnixMilli()
params["endTime"] = end.UnixMilli()
@@ -368,7 +398,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
params["startTime"] = now.AddDate(0, 0, -rt.Int("days")).UnixMilli()
params["endTime"] = now.UnixMilli()
}
return params, nil
return params, resolvedFilters, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
@@ -11,6 +11,7 @@ import (
"errors"
"reflect"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -22,6 +23,8 @@ type searchMsgExecutionCaller struct {
failEnrichment bool
omitPagination bool
omitMgetItem bool
firstResponse string
mgetResponse string
}
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -40,6 +43,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
@@ -48,6 +54,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.omitMgetItem {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil
}
if f.mgetResponse != "" {
return searchMsgToolResult(f.mgetResponse), nil
}
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"},{"openMessageId":"m2","content":"detail-2"}]}`), nil
default:
return nil, errors.New("unexpected tool")
@@ -84,7 +93,7 @@ func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...st
return payload
}
func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
@@ -142,7 +151,7 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
}
}
func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{failSecondPage: true}
payload := executeSearchMsg(t, caller,
"--query", "周报",
@@ -160,7 +169,7 @@ func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
}
}
func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
caller := &searchMsgExecutionCaller{failEnrichment: true}
payload := executeSearchMsg(t, caller, "--query", "周报")
if payload["complete"] != false || payload["count"] != float64(1) ||
@@ -169,7 +178,7 @@ func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
}
}
func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{omitPagination: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
if payload["complete"] != false || payload["count"] != float64(1) ||
@@ -183,7 +192,7 @@ func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
}
}
func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{omitMgetItem: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")
if payload["complete"] != false || payload["count"] != float64(2) ||
@@ -199,3 +208,34 @@ func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
t.Fatalf("failure = %#v", failure)
}
}
func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) {
caller := &searchMsgExecutionCaller{
firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--start-time", "2026-07-01T00:00:00+08:00",
"--end-time", "2026-07-03T00:00:00+08:00",
"--sort", "asc",
"--no-enrich",
)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
wantStart, _ := time.Parse(time.RFC3339, "2026-07-01T00:00:00+08:00")
wantEnd, _ := time.Parse(time.RFC3339, "2026-07-03T00:00:00+08:00")
if caller.calls[0].args["startTime"] != wantStart.UnixMilli() ||
caller.calls[0].args["endTime"] != wantEnd.UnixMilli() {
t.Fatalf("time params = %#v", caller.calls[0].args)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m1" ||
messages[1].(map[string]any)["messageId"] != "m2" {
t.Fatalf("ascending messages = %#v", messages)
}
rangeMeta := payload["queryRange"].(map[string]any)
if rangeMeta["order"] != "asc" || rangeMeta["semantics"] != "[start,end)" {
t.Fatalf("queryRange = %#v", rangeMeta)
}
}
+459 -65
View File
@@ -14,25 +14,36 @@
package smart
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const (
threadRepliesDefaultPageLimit = 50
threadRepliesHardPageLimit = 500
)
// ThreadReplies: fetch every reply under one topic ("话题") message and print a
// clean projected list (speaker / text / time) instead of a raw dump.
//
// Steps:
// 1. call list_topic_replies (chat server) with openconversationId=--group and
// topicId=--topic-id, optionally startTime=--time and pageSize=--limit —
// param keys copied verbatim from chat.go's list-topic-replies call site;
// 2. defensively unwrap the reply list (multiple candidate container keys) and
// 1. accept either --group plus --thread-id/--topic-id, or resolve a root
// --message-id to its conversation/thread context through list_messages_by_ids;
// 2. call list_topic_replies (chat server), optionally with startTime=--time
// and pageSize=--limit;
// 3. defensively unwrap the reply list (multiple candidate container keys) and
// project each reply to {sender, text, createTime} tolerating field aliases;
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
// 4. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
//
// The default path only reads and reshapes topic replies;
// --download-resources additionally writes resource files locally.
@@ -42,10 +53,11 @@ var ThreadReplies = shortcut.Shortcut{
Service: "chat",
Command: "+thread-replies",
Product: "chat",
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
Intent: "当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
@@ -60,101 +72,483 @@ var ThreadReplies = shortcut.Shortcut{
CLIPath: "chat +thread-replies",
PrimaryCLIPath: "chat +thread-replies",
},
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
Reason: "Reviewed thread reader adapter: it accepts a root message ID or threadId/topicId, reads lower topic replies, projects stable message fields, orders complete results, and optionally downloads reply resources safely.",
},
Selection: contract.SelectionSpec{
AgentSummary: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
AgentSummary: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
Examples: []string{
"dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc",
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads",
},
},
},
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID;--thread-id/--topic-id 必须同时提供 --group;--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "话题主消息 openMessageId;自动只读解析 conversationId 和 threadId;--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId);--thread-id/--topic-id 必须同时提供 --group"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名;--thread-id/--topic-id 必须同时提供 --group"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数;--limit 必须大于 0"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的公开兼容别名;必须大于 0"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿下层毫秒级 nextCursor 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;asc 必须与 --page-all 一起使用"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "回复输出顺序 asc/desc(可选,默认 desc;asc 必须与 --page-all 一起使用)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须与 --page-all 一起使用)"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintExactlyOne,
Flags: []string{"thread-id", "topic-id"},
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
Flags: []string{"message-id", "thread-id", "topic-id"},
Description: "--message-id、--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "thread-id", "topic-id"}, Description: "--thread-id/--topic-id 必须同时提供 --group"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "message-id"}, Description: "--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort", "page-all"}, Description: "asc 必须与 --page-all 一起使用"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --page-size 50 --page-all --page-limit 20`,
},
Validate: validateThreadReplies,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — fetch the topic replies. Param keys (openconversationId /
// topicId / startTime / pageSize) are copied verbatim from chat.go's
// list-topic-replies call site.
params := map[string]any{
"openconversationId": rt.Str("group"),
"topicId": rt.StrFirst("thread-id", "topic-id"),
"forward": false,
}
if rt.Changed("time") && rt.Str("time") != "" {
params["startTime"] = rt.Str("time")
}
if rt.Changed("limit") && rt.Int("limit") > 0 {
params["pageSize"] = rt.Int("limit")
}
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
return err
}
// Step 2 — defensively unwrap and project. Response shape has no
// contract, so probe multiple candidate container/field keys.
items := threadReplyItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions")))
}
payload := map[string]any{
"replies": results,
"count": len(results),
}
chatmsg.ApplyMessagePagination(payload, data, items, "older")
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")),
)
}
return rt.Output(payload)
},
Execute: executeThreadReplies,
}
func validateThreadReplies(rt *shortcut.RuntimeContext) error {
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
return err
}
if rt.Changed("limit") && rt.Int("limit") <= 0 {
return localChatOptionError("invalid_page_size", "+thread-replies 的 --limit 必须大于 0", "--limit")
for _, name := range []string{"limit", "page-size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return localChatOptionError("invalid_page_size", "+thread-replies 的 --"+name+" 必须大于 0", "--"+name)
}
}
if value := strings.TrimSpace(rt.Str("time")); value != "" && !validChatTime(value) {
return localChatOptionError("invalid_time_boundary", "+thread-replies 的 --time 格式无效", "--time")
}
if strings.TrimSpace(rt.Str("message-id")) == "" && strings.TrimSpace(rt.Str("group")) == "" {
return apperrors.NewValidation("--thread-id/--topic-id 必须同时提供 --group")
}
if threadRepliesOrder(rt) == "asc" && !rt.Bool("page-all") {
return apperrors.NewValidation("asc 必须与 --page-all 一起使用")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > threadRepliesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func executeThreadReplies(rt *shortcut.RuntimeContext) error {
target, err := resolveThreadRepliesTarget(rt)
if err != nil {
return err
}
params := map[string]any{
"openconversationId": target.conversationID,
"topicId": target.threadID,
"forward": false,
}
if value := strings.TrimSpace(rt.Str("time")); value != "" {
params["startTime"] = value
}
if pageSize := rt.IntFirst("limit", "page-size"); pageSize > 0 {
params["pageSize"] = pageSize
}
var payload map[string]any
var items []map[string]any
err = nil
if rt.Bool("page-all") {
payload, items, err = collectAllThreadReplies(rt, params)
} else {
payload, items, err = collectOneThreadRepliesPage(rt, params)
}
applyThreadRepliesResultContract(payload, items, target, threadRepliesOrder(rt))
if err != nil {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return err
}
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
chatshortcut.DownloadMessageResources(rt, items, target.conversationID),
)
}
return rt.Output(payload)
}
type threadRepliesTarget struct {
conversationID string
threadID string
resolvedFromMessageID string
}
func resolveThreadRepliesTarget(rt *shortcut.RuntimeContext) (threadRepliesTarget, error) {
messageID := strings.TrimSpace(rt.Str("message-id"))
if messageID == "" {
return threadRepliesTarget{
conversationID: strings.TrimSpace(rt.Str("group")),
threadID: strings.TrimSpace(rt.StrFirst("thread-id", "topic-id")),
}, nil
}
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{
"openMsgIds": []string{messageID},
})
if err != nil {
return threadRepliesTarget{}, err
}
var matched map[string]any
for _, item := range threadReplyItems(data) {
if threadRepliesString(chatmsg.MessageID(item)) == messageID {
matched = item
break
}
}
if matched == nil {
return threadRepliesTarget{}, apperrors.NewAPI(
"未找到指定的话题主消息,无法解析 thread",
apperrors.WithOperation("im/list_messages_by_ids"),
apperrors.WithReason("thread_root_message_not_found"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithHint("请确认 --message-id 是可读取的话题主消息 openMessageId"),
)
}
conversationID := threadRepliesString(chatmsg.ConversationID(matched))
threadID := threadRepliesString(chatmsg.ThreadID(matched))
if conversationID == "" || threadID == "" {
return threadRepliesTarget{}, apperrors.NewAPI(
"消息详情未返回 conversationId/threadId,无法读取话题回复",
apperrors.WithOperation("im/list_messages_by_ids"),
apperrors.WithReason("thread_context_missing"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithHint("请确认 --message-id 指向话题主消息,或改用 --group 与 --thread-id"),
)
}
if explicitGroup := strings.TrimSpace(rt.Str("group")); explicitGroup != "" && explicitGroup != conversationID {
return threadRepliesTarget{}, apperrors.NewValidation("--group 与 --message-id 解析出的 conversationId 不匹配")
}
return threadRepliesTarget{
conversationID: conversationID,
threadID: threadID,
resolvedFromMessageID: messageID,
}, nil
}
func threadRepliesOrder(rt *shortcut.RuntimeContext) string {
if order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))); order != "" {
return order
}
return "desc"
}
func applyThreadRepliesResultContract(payload map[string]any, items []map[string]any, target threadRepliesTarget, order string) {
if payload == nil {
return
}
if order == "asc" {
reverseThreadReplyMaps(items)
if replies, ok := payload["replies"].([]map[string]any); ok {
reverseThreadReplyMaps(replies)
}
}
payload["order"] = order
if payload["complete"] == true {
payload["orderScope"] = "complete_result"
} else {
payload["orderScope"] = "fetched_pages"
}
payload["conversationId"] = target.conversationID
payload["threadId"] = target.threadID
if target.resolvedFromMessageID != "" {
payload["resolvedFromMessageId"] = target.resolvedFromMessageID
}
}
func reverseThreadReplyMaps(items []map[string]any) {
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
}
func threadRepliesString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
func collectOneThreadRepliesPage(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
return nil, nil, err
}
items := threadReplyItems(data)
payload := newThreadRepliesPayload(items, !rt.Bool("no-reactions"))
applyOneThreadRepliesPagination(payload, data)
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else if payload["failedCount"] != 0 {
payload["stopReason"] = "pagination_error"
} else {
payload["stopReason"] = "single_page"
}
return payload, items, nil
}
func applyOneThreadRepliesPagination(payload, data map[string]any) {
payload["pagesFetched"] = 1
page := chatmsg.Pagination(data)
hasMore, hasMoreKnown := page["hasMore"].(bool)
if !hasMoreKnown {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
}}
return
}
payload["paginationKnown"] = true
payload["hasMore"] = hasMore
payload["complete"] = !hasMore
if !hasMore {
return
}
_, boundary, err := threadRepliesNextCursorBoundary(page["nextCursor"])
if err != nil {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + err.Error(),
}}
return
}
payload["nextPage"] = map[string]any{
"direction": "older",
"time": boundary,
"nextCursor": page["nextCursor"],
}
}
func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), threadRepliesDefaultPageLimit)
seenIDs := map[string]bool{}
seenCursors := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextPage map[string]any
for pagesFetched < pageLimit {
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
rawItems := threadReplyItems(data)
for _, item := range rawItems {
stableID := chatmsg.StableMessageID(item)
if stableID != "" && seenIDs[stableID] {
continue
}
if stableID != "" {
seenIDs[stableID] = true
}
allItems = append(allItems, item)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
if !hasMoreKnown {
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !pageHasMore {
complete = true
hasMore = false
nextPage = nil
stopReason = "source_complete"
break
}
if len(rawItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "下层返回 hasMore=true 但当前页没有回复,无法证明分页可以安全推进",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := threadRepliesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "hasMore=true 但 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": "older",
"time": boundary,
"nextCursor": page["nextCursor"],
}
params["startTime"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := newThreadRepliesPayload(allItems, !rt.Bool("no-reactions"))
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = paginationKnown
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
if hasMore && nextPage != nil {
payload["nextPage"] = nextPage
}
if len(failures) > 0 {
failureStage := "pagination"
if stopReason == "read_failure" {
failureStage = "read"
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("话题回复全量读取未完成:%d 页成功,%d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/list_topic_replies"),
apperrors.WithReason("thread_replies_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage(failureStage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
)
}
return payload, allItems, nil
}
// threadRepliesNextCursorBoundary converts the authoritative millisecond
// cursor returned by list_topic_replies into the exact RFC3339 boundary that
// the same lower tool accepts as startTime. Message createTime is deliberately
// not used here: it is only second-precision and can skip replies when a page
// boundary splits several messages created within the same second.
func threadRepliesNextCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
cursorKey := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return cursorKey, boundary, nil
}
func newThreadRepliesPayload(items []map[string]any, includeReactions bool) map[string]any {
results := make([]map[string]any, 0, len(items))
for _, item := range items {
results = append(results, projectChatMessageWithReactions(item, includeReactions))
}
return map[string]any{
"contractVersion": chatmsg.MessageListContractVersion,
"replies": results,
"count": len(results),
"pagesFetched": 0,
"paginationKnown": false,
"complete": false,
"hasMore": false,
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
}
}
// threadReplyItems defensively unwraps the reply list from the response,
// tolerating the common container keys and one level of nesting under a
// "result"/"data" wrapper.
@@ -0,0 +1,492 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"bytes"
"encoding/json"
stderrors "errors"
"math"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageThreadRepliesResolvesRootMessageIDBeforeReadingReplies(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"root-message","openConversationId":"cid","openConvThreadId":"thread","createTime":"2026-08-05 16:48:30"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"reply-1","createTime":"2026-08-05 19:46:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+thread-replies", "--message-id", "root-message"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || !reflect.DeepEqual(caller.args[0]["openMsgIds"], []string{"root-message"}) ||
caller.args[1]["openconversationId"] != "cid" || caller.args[1]["topicId"] != "thread" ||
caller.args[1]["forward"] != false {
t.Fatalf("resolution calls = %#v", caller.args)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["conversationId"] != "cid" || payload["threadId"] != "thread" ||
payload["resolvedFromMessageId"] != "root-message" || payload["order"] != "desc" ||
payload["orderScope"] != "complete_result" || payload["count"] != float64(1) {
t.Fatalf("resolved payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesMessageResolutionFailsClosedWithoutThreadContext(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"ordinary-message","openConversationId":"cid"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+thread-replies", "--message-id", "ordinary-message"})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || typed.Reason != "thread_context_missing" {
t.Fatalf("error = %#v", err)
}
if len(caller.args) != 1 || !reflect.DeepEqual(caller.args[0]["openMsgIds"], []string{"ordinary-message"}) {
t.Fatalf("unexpected fallback calls = %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesMessageResolutionRejectsConversationMismatch(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"root-message","openConversationId":"resolved-cid","openConvThreadId":"thread"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+thread-replies", "--message-id", "root-message", "--group", "different-cid",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
t.Fatalf("error = %#v", err)
}
if len(caller.args) != 1 {
t.Fatalf("mismatch reached replies/contact fallback: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllOrdersCompleteResultAscending(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m3","createTime":"2026-08-06 21:28:41"},{"openMessageId":"m2","createTime":"2026-08-06 21:28:40"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 19:46:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-all", "--sort", "asc",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
replies, _ := payload["replies"].([]any)
if len(replies) != 3 || replies[0].(map[string]any)["messageId"] != "m1" ||
replies[1].(map[string]any)["messageId"] != "m2" ||
replies[2].(map[string]any)["messageId"] != "m3" || payload["order"] != "asc" ||
payload["orderScope"] != "complete_result" || payload["complete"] != true {
t.Fatalf("ascending payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesAscendingRequiresPageAll(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--order", "asc",
})
if err := root.Execute(); err == nil {
t.Fatal("single-page asc unexpectedly succeeded")
}
if len(caller.args) != 0 {
t.Fatalf("invalid asc reached lower read: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesThreadSelectorRequiresGroup(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+thread-replies", "--thread-id", "thread"})
if err := root.Execute(); err == nil {
t.Fatal("thread selector without group unexpectedly succeeded")
}
if len(caller.args) != 0 {
t.Fatalf("invalid selector reached lower read: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllUsesMillisecondCursorAndDeduplicates(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m2","createTime":"2026-08-06 21:28:40","content":"同秒第一条"},{"openMessageId":"m1","createTime":"2026-08-06 21:28:40","content":"同秒第二条"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:40","content":"重复边界"},{"openMessageId":"m0","createTime":"2026-08-06 21:28:39","content":"下一页"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-size", "2", "--page-all", "--page-limit", "5", "--no-reactions",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[0]["pageSize"] != 2 ||
caller.args[0]["forward"] != false || caller.args[1]["startTime"] != "2026-08-06T13:28:39.361Z" {
t.Fatalf("pagination calls = %#v", caller.args)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
payload["stopReason"] != "source_complete" || payload["failedCount"] != float64(0) {
t.Fatalf("all-page payload = %#v", payload)
}
replies, _ := payload["replies"].([]any)
if len(replies) != 3 || replies[0].(map[string]any)["messageId"] != "m2" ||
replies[1].(map[string]any)["messageId"] != "m1" ||
replies[2].(map[string]any)["messageId"] != "m0" {
t.Fatalf("deduplicated replies = %#v", replies)
}
}
func TestCrossPlatformCoverageThreadRepliesSinglePagePublishesMillisecondContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-size", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
next, _ := payload["nextPage"].(map[string]any)
if payload["complete"] != false || payload["hasMore"] != true ||
payload["paginationKnown"] != true || payload["failedCount"] != float64(0) ||
payload["stopReason"] != "single_page" || next["time"] != "2026-08-06T13:28:39.361Z" ||
next["nextCursor"] != float64(1786022919361) {
t.Fatalf("single-page payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesSinglePageFailsClosedWithoutCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-size", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["complete"] != false || payload["hasMore"] != true ||
payload["paginationKnown"] != false || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "pagination_error" || payload["nextPage"] != nil {
t.Fatalf("single-page missing-cursor payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllAcceptsEmptyTerminalPage(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
`{"result":{"hasMore":false,"messages":[]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--limit", "1", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) {
t.Fatalf("empty terminal page payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllPublishesBoundedContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-all", "--page-limit", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
next, _ := payload["nextPage"].(map[string]any)
if len(caller.args) != 1 || payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
next["time"] != "2026-08-06T13:28:39.361Z" || next["direction"] != "older" ||
next["nextCursor"] != float64(1786022919361) {
t.Fatalf("bounded payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllReturnsPartialLedgerOnLaterFailure(t *testing.T) {
caller := &chatMessagesPagingCaller{
responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
},
failAt: 2,
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
typed.Reason != "thread_replies_incomplete" || !typed.Retryable ||
typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["partial"] != true || payload["complete"] != false ||
payload["count"] != float64(1) || payload["pagesFetched"] != float64(1) ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("partial payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllFailsClosedWithoutCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "thread_replies_incomplete" {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 1 || payload["complete"] != false ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("stalled payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllFailsClosedOnStalledCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m2","createTime":"2026-08-06 21:28:40"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "thread_replies_incomplete" {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["complete"] != false ||
payload["count"] != float64(2) || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "pagination_error" {
t.Fatalf("stalled cursor payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesNextCursorBoundaryValidation(t *testing.T) {
for _, test := range []struct {
name string
value any
wantKey string
wantTime string
wantError bool
}{
{name: "integer", value: int64(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "native integer", value: int(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "int32", value: int32(1234), wantKey: "1234", wantTime: "1970-01-01T00:00:01.234Z"},
{name: "float32", value: float32(1024), wantKey: "1024", wantTime: "1970-01-01T00:00:01.024Z"},
{name: "json number", value: float64(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "string", value: "1786022919361", wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "missing", value: nil, wantError: true},
{name: "fractional", value: 1786022919361.5, wantError: true},
{name: "float32 nan", value: float32(math.NaN()), wantError: true},
{name: "float32 infinity", value: float32(math.Inf(1)), wantError: true},
{name: "zero", value: 0, wantError: true},
{name: "negative", value: -1, wantError: true},
{name: "opaque", value: "not-a-millisecond-cursor", wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
key, boundary, err := threadRepliesNextCursorBoundary(test.value)
if test.wantError {
if err == nil {
t.Fatalf("threadRepliesNextCursorBoundary(%#v) = %q, %q, nil", test.value, key, boundary)
}
return
}
if err != nil || key != test.wantKey || boundary != test.wantTime {
t.Fatalf("threadRepliesNextCursorBoundary(%#v) = %q, %q, %v", test.value, key, boundary, err)
}
})
}
}
func TestCrossPlatformCoverageThreadRepliesAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, writer any, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
if failWriter, ok := writer.(chatMessagesFailWriter); ok {
root.SetOut(failWriter)
} else {
root.SetOut(&output)
}
root.SetArgs(append([]string{"chat", "+thread-replies"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
return decodeThreadRepliesPayload(t, output.Bytes()), err
}
t.Run("explicit time and single read failure", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[]}}`,
}}, nil, "--group", "cid", "--thread-id", "thread", "--time", "2026-08-06")
if err != nil || payload["complete"] != true {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if _, err = run(t, &chatMessagesPagingCaller{failAt: 1}, nil,
"--group", "cid", "--thread-id", "thread"); err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("root-message resolution failures", func(t *testing.T) {
if _, err := run(t, &chatMessagesPagingCaller{failAt: 1}, nil, "--message-id", "root"); err == nil {
t.Fatal("message-detail read failure unexpectedly succeeded")
}
if _, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"other"}]}}`,
}}, nil, "--message-id", "root"); err == nil {
t.Fatal("missing root message unexpectedly succeeded")
}
})
for _, tc := range []struct {
name string
response string
wantStop string
}{
{name: "unknown pagination", response: `{"result":{"messages":[]}}`, wantStop: "pagination_error"},
{name: "empty continuation page", response: `{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[]}}`, wantStop: "pagination_error"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{tc.response}}, nil,
"--group", "cid", "--thread-id", "thread", "--page-all")
if err == nil || payload["stopReason"] != tc.wantStop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("failure ledger output error", func(t *testing.T) {
_, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[]}}`,
}}, chatMessagesFailWriter{}, "--group", "cid", "--thread-id", "thread", "--page-all")
if err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
applyThreadRepliesResultContract(nil, nil, threadRepliesTarget{}, "desc")
if got := threadRepliesString("<nil>"); got != "" {
t.Fatalf("threadRepliesString(<nil>) = %q", got)
}
}
func TestCrossPlatformCoverageThreadRepliesPaginationValidationStopsBeforeRead(t *testing.T) {
for _, args := range [][]string{
{"--limit", "0"},
{"--page-size", "0"},
{"--limit", "1", "--page-size", "1"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
}, args...))
if err := root.Execute(); err == nil {
t.Errorf("invalid pagination succeeded: %v", args)
}
if len(caller.args) != 0 {
t.Errorf("invalid pagination reached read: %v calls=%#v", args, caller.args)
}
}
}
func decodeThreadRepliesPayload(t *testing.T, raw []byte) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode thread replies payload: %v\n%s", err, raw)
}
return payload
}
+18 -2
View File
@@ -29,6 +29,7 @@ const (
const (
chatResolutionPageSize = 10
chatResolutionMaxWindowSize = 100
chatResolutionPageLimit = 40
userSearchDefaultResultLimit = 20
)
@@ -192,13 +193,15 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) {
)
}
cursor := "0"
requestPageSize := chatResolutionPageSize
maxWindowProbeUsed := false
seenCursors := map[string]bool{cursor: true}
chats := make([]Chat, 0)
complete := false
for pageNumber := 1; pageNumber <= chatResolutionPageLimit; pageNumber++ {
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
"keyword": query,
"limit": chatResolutionPageSize,
"limit": requestPageSize,
"cursor": cursor,
})
if err != nil {
@@ -209,12 +212,25 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) {
page := extractChatPagination(data)
switch {
case page.hasMoreKnown && !page.hasMore:
if len(rows) >= requestPageSize && page.nextCursor == "" &&
(pageNumber == 1 || (maxWindowProbeUsed && pageNumber == 2)) {
if cursor == "0" && !maxWindowProbeUsed && requestPageSize < chatResolutionMaxWindowSize && pageNumber < chatResolutionPageLimit {
requestPageSize = chatResolutionMaxWindowSize
maxWindowProbeUsed = true
continue
}
return ChatResolution{}, newIncompleteChatResolutionError(
query,
dedupeChats(chats),
"群搜索返回满页并声明 hasMore=false,但没有可用 nextCursor,无法证明候选完整",
)
}
complete = true
case page.hasMoreKnown && page.hasMore:
// A usable cursor is required below.
case page.nextCursor != "":
// Some versions omit hasMore but still publish a continuation cursor.
case len(rows) < chatResolutionPageSize:
case len(rows) < requestPageSize:
// Compatibility for older responses that return a short bare array
// without pagination metadata.
complete = true
@@ -370,6 +370,31 @@ func TestCrossPlatformCoverageResolveChatFailsClosedWhenPaginationCannotAdvance(
}
}
func TestCrossPlatformCoverageResolveChatRejectsFullMaximumProbeWithoutCursor(t *testing.T) {
page := func(size int, prefix string) []any {
rows := make([]any, size)
for i := range rows {
rows[i] = map[string]any{
"openConversationId": fmt.Sprintf("%s-%d", prefix, i),
"title": fmt.Sprintf("项目群候选-%s-%d", prefix, i),
}
}
return rows
}
reader := &chatResolutionReader{responses: []map[string]any{
{"result": page(chatResolutionPageSize, "first"), "hasMore": false},
{"result": page(chatResolutionMaxWindowSize, "probe"), "hasMore": false},
}}
_, err := ResolveChat(reader, "项目群")
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error = %#v", err)
}
if len(reader.calls) != 2 || reader.calls[1]["limit"] != chatResolutionMaxWindowSize {
t.Fatalf("calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPaginationMetadata(t *testing.T) {
reader := &chatResolutionReader{responses: []map[string]any{{
"result": []any{
@@ -382,6 +407,33 @@ func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPagination
}
}
func TestCrossPlatformCoverageResolveChatProbesMaximumWindowBeforeSelecting(t *testing.T) {
firstPage := make([]any, chatResolutionPageSize)
for i := range firstPage {
firstPage[i] = map[string]any{
"openConversationId": fmt.Sprintf("archive-%d", i),
"title": fmt.Sprintf("项目群-归档-%d", i),
}
}
reader := &chatResolutionReader{responses: []map[string]any{
{"result": map[string]any{"groups": firstPage, "hasMore": false}},
{"result": map[string]any{"groups": []any{
map[string]any{"openConversationId": "active", "title": "项目群"},
}, "hasMore": false}},
}}
resolved, err := ResolveChat(reader, "项目群")
if err != nil {
t.Fatal(err)
}
if resolved.Selected.OpenConversationID != "active" || resolved.MatchType != "exact" {
t.Fatalf("resolved = %#v", resolved)
}
if len(reader.calls) != 2 || reader.calls[0]["limit"] != chatResolutionPageSize ||
reader.calls[1]["limit"] != chatResolutionMaxWindowSize || reader.calls[1]["cursor"] != "0" {
t.Fatalf("calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageResolutionErrorCarriesStructuredCandidates(t *testing.T) {
err := newResolutionError(StatusAmbiguous, "chat", "项目群", []Chat{
{OpenConversationID: "c1", Name: "项目群"},
@@ -1,5 +1,5 @@
#!/bin/sh
# Mono↔multi skill content QA (G1–G4). See docs/skill-mono-multi-qa.md
# Mono↔multi skill content QA (G1–G5). See docs/skill-mono-multi-qa.md
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+5 -3
View File
@@ -10,14 +10,16 @@ chat_skill="skills/multi/dingtalk-chat/SKILL.md"
event_skill="skills/multi/dingtalk-misc/references/event.md"
mono_skill="skills/mono/SKILL.md"
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_max_bytes=10000
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
event_max_bytes=10000
runtime_contract_max_bytes=3000
chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')"
if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (max ${chat_max_bytes})" >&2
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (target ${chat_target_bytes}, max ${chat_max_bytes} with ${chat_max_overage_percent}% allowance)" >&2
exit 1
fi
@@ -106,4 +108,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
+21 -9
View File
@@ -312,10 +312,13 @@ func checkCompatibility(root *cobra.Command, baseline interfaceContract) []strin
continue
}
if actual.Value.Type() != expectedFlag.Type {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(),
))
if !reviewedFlagTypeChange(displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type()) ||
flagContractOtherwiseChanged(expectedFlag, actual, actualScope) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(),
))
}
}
if expectedFlag.Shorthand != "" && actual.Shorthand != expectedFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
@@ -443,11 +446,20 @@ func mergeContracts(historical, current interfaceContract) (interfaceContract, [
continue
}
if oldFlag.Type != newFlag.Type {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
if !reviewedFlagTypeChange(displayPath(path), name, oldFlag.Type, newFlag.Type) ||
mergedFlagContractOtherwiseChanged(oldFlag, newFlag) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
}
// A reviewed migration keeps merging, and the merged entry keeps
// the historical type below, exactly as this function keeps the
// historical shorthand, required and hidden values. The baseline
// records what was promised, so a later --check still resolves
// this flag through the reviewed table rather than silently
// adopting the new type as history.
}
if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/spf13/cobra"
)
@@ -239,6 +240,286 @@ func assertFailureContains(t *testing.T, failures []string, want string) {
t.Fatalf("failures %v do not contain %q", failures, want)
}
// policyRoot builds "dws old" with a single --policy flag declared by declare,
// so a baseline and a candidate can differ only in how that flag is typed.
func policyRoot(declare func(*cobra.Command)) *cobra.Command {
root := &cobra.Command{Use: "dws"}
old := &cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}}
declare(old)
root.AddCommand(old)
root.InitDefaultHelpCmd()
return root
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
t.Helper()
if _, exists := reviewedFlagTypeChanges[change]; exists {
t.Fatalf("fixture %+v collides with a production entry", change)
}
reviewedFlagTypeChanges[change] = struct{}{}
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
}
const typeChangedFailure = "changed type from string to int"
func TestReviewedFlagTypeChangeAcceptsRegisteredMigration(t *testing.T) {
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
})
// Without an entry the migration is still a blocking change.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
if failures := checkCompatibility(current, baseline); len(failures) != 0 {
t.Fatalf("a reviewed string->int migration should pass: %v", failures)
}
}
func TestReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
// The reverse migration shares command and flag but not direction, so the
// string->int entry must not admit it.
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
})
assertFailureContains(t, checkCompatibility(current, baseline), "changed type from int to string")
}
func TestReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws other", Flag: "policy", From: "string", To: "int"})
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
})
// Same flag and direction, different command.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "elsewhere", From: "string", To: "int"})
// Same command and direction, different flag.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
}
// A reviewed migration is only accepted when the rest of the flag's contract
// held still. Each case bundles one unrelated regression with the reviewed type
// change and requires the type failure to reappear, which also anchors
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
// drifts out of sync fails here instead of silently widening the exemption.
func TestReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
tests := []struct {
name string
baseline func(*cobra.Command)
current func(*cobra.Command)
want string
}{
{
name: "became required",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.MarkFlagRequired("policy")
},
want: "became required",
},
{
name: "became hidden",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.Flags().MarkHidden("policy")
},
want: "became hidden",
},
{
name: "lost shorthand",
baseline: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
want: "lost shorthand",
},
{
name: "changed no-opt value",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
command.Flags().Lookup("policy").NoOptDefVal = "4"
},
want: "changed no-opt value",
},
{
name: "narrowed persistent scope",
baseline: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
want: "narrowed persistent scope",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failures := checkCompatibility(policyRoot(test.current), snapshot(policyRoot(test.baseline)))
assertFailureContains(t, failures, test.want)
assertFailureContains(t, failures, typeChangedFailure)
})
}
}
// mergeContracts runs the same admission decision on --merge. A reviewed
// migration keeps merging and the merged entry keeps the historical type, so a
// later --check still resolves the flag through the reviewed table.
func TestMergeAcceptsReviewedFlagTypeChangeAndKeepsHistoricalType(t *testing.T) {
historical := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
}))
if _, failures := mergeContracts(historical, current); len(failures) == 0 {
t.Fatal("an unreviewed merge should report the type change")
}
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
merged, failures := mergeContracts(historical, current)
if len(failures) != 0 {
t.Fatalf("a reviewed merge should pass: %v", failures)
}
if got := merged.Commands["old"].Flags["policy"].Type; got != "string" {
t.Fatalf("merged type = %q, want the historical %q", got, "string")
}
}
// mergeContracts applies the same "nothing else moved" condition as
// checkCompatibility, but against two recorded contracts rather than a live
// pflag definition. Each case bundles one unrelated regression with the reviewed
// type change and requires the type failure to reappear, anchoring
// mergedFlagContractOtherwiseChanged against the checks it mirrors.
func TestMergeRejectsReviewedFlagTypeChangeWithBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
tests := []struct {
name string
historical func(*cobra.Command)
current func(*cobra.Command)
}{
{
name: "lost shorthand",
historical: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
},
{
name: "became required",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.MarkFlagRequired("policy")
},
},
{
name: "became hidden",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.Flags().MarkHidden("policy")
},
},
{
name: "changed no-opt value",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
command.Flags().Lookup("policy").NoOptDefVal = "4"
},
},
{
name: "narrowed persistent scope",
historical: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, failures := mergeContracts(
snapshot(policyRoot(test.historical)),
snapshot(policyRoot(test.current)),
)
assertFailureContains(t, failures, typeChangedFailure)
})
}
}
// The behaviour tests above register their own fixtures, so they cannot catch a
// production entry whose key is spelled in the wrong form — that mistake
// disables the exemption silently and only a real gate run reports it. Recompute
// every key through displayPath instead of trusting the spelling in the table.
func TestReviewedFlagTypeChangeKeysUseDisplayPaths(t *testing.T) {
if len(reviewedFlagTypeChanges) == 0 {
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
}
for change := range reviewedFlagTypeChanges {
if got := displayPath(internalCommandPath(change.CommandPath)); got != change.CommandPath {
t.Errorf("命令路径不是 displayPath 的规范形态\n 登记: %s\n 规范: %s", change.CommandPath, got)
}
if change.Flag == "" || strings.HasPrefix(change.Flag, "-") {
t.Errorf("%s: flag 名应是裸名(lookupFlag 用裸名查找),登记为 %q", change.CommandPath, change.Flag)
}
if change.From == "" || change.To == "" || change.From == change.To {
t.Errorf("%s --%s: %q -> %q 不构成类型迁移", change.CommandPath, change.Flag, change.From, change.To)
}
}
}
// Anchor every production entry to the real command tree. A misspelled command
// or flag name would leave the exemption permanently inert, and the behaviour
// tests cannot see that because they register their own paths.
func TestReviewedFlagTypeChangeEntriesResolveInTheRealCommandTree(t *testing.T) {
if len(reviewedFlagTypeChanges) == 0 {
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
}
root := app.NewRootCommand()
for change := range reviewedFlagTypeChanges {
command, resolved := resolveCommand(root, internalCommandPath(change.CommandPath))
if !resolved {
t.Errorf("%q 在真实命令树中不存在", change.CommandPath)
continue
}
flag, _ := lookupFlag(command, change.Flag)
if flag == nil {
t.Errorf("%q 上不存在 --%s", change.CommandPath, change.Flag)
continue
}
// Exactly one side holds depending on whether the migration has landed:
// From before it does, To afterwards. Asserting membership catches a
// misspelled type name without failing while the migration is pending.
if actual := flag.Value.Type(); actual != change.From && actual != change.To {
t.Errorf("%q --%s 当前类型 %q 既不是 %q 也不是 %q",
change.CommandPath, change.Flag, actual, change.From, change.To)
}
}
}
// internalCommandPath inverts displayPath so a table key can be fed back into
// resolveCommand and displayPath.
func internalCommandPath(displayed string) string {
if displayed == "dws" {
return "root"
}
return strings.ReplaceAll(strings.TrimPrefix(displayed, "dws "), " ", ".")
}
func testRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
old := &cobra.Command{Use: "old"}
@@ -0,0 +1,140 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import "github.com/spf13/pflag"
// flagTypeChange identifies one flag type migration exactly: which command,
// which flag, and in which direction. Nothing is matched by wildcard — an entry
// that differs in any of the four fields does not apply.
//
// CommandPath is the display form produced by displayPath ("dws minutes
// permission apply"), not the dot-separated internal path. Writing the internal
// form here would silently disable the exemption instead of failing loudly, so
// TestReviewedFlagTypeChangeKeysUseDisplayPaths recomputes every key through
// displayPath rather than trusting the spelling below.
//
// This table is duplicated in internal/interfacesnapshot/reviewed.go and the two
// must stay identical; the interfacesnapshot package has a test that fails if
// they drift. The duplication is forced rather than chosen:
// check-authoritative-interface-baselines.sh copies this whole directory into a
// worktree checked out at a historical revision and builds it there, so nothing
// here may import a package this branch adds — that build would fail with an
// unresolved import instead of reporting a compatibility result.
type flagTypeChange struct {
CommandPath string
Flag string
From string
To string
}
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
// migrations this gate accepts. A flag type is part of the published CLI
// contract, and a type swap cannot be proven safe from the type names alone —
// whether old invocations still parse depends on the concrete value domain the
// command enforces. So a migration is accepted only when this exact command,
// flag, and old→new pair appear here. Adding an entry is a contract decision
// and belongs in review, not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "int" is a
// separate key from "int" → "string" and only the reviewed direction is
// accepted. The reverse is not a candidate for this table — widening an int
// flag back to string lets values the parser used to reject reach RunE, which
// is a genuine loosening of validation rather than a type migration.
//
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
// only when nothing else about the flag moved. See the callers in
// checkCompatibility and mergeContracts.
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag parsed
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
// same [2,4] domain check in RunE.
//
// This is admissible because the historical set of *successful* invocations
// is a subset of the new one, not merely similar to it:
//
// - Old: the value had to parse as base-10 and land in [2,4]. That is
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
// ("+3", "03", "003", ...).
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
// resolves to the same number under base 0, so no historical success
// becomes a failure.
// - Base 0 additionally accepts "0x3" and friends, which the old parser
// rejected. That widens the accepted set; it cannot break a caller who
// was already succeeding.
// - Values outside [2,4] and non-numeric values still fail. Only the
// failure *message and timing* move, from RunE to flag parsing.
//
// The flag's default value changes from "" to "0" as an unavoidable
// consequence of the type. Neither gate compares defaults, and a default is
// not reachable by a caller here because RunE requires the flag to be
// explicitly set, so this is not a contract change.
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
}
// reviewedFlagTypeChange reports whether this exact command, flag and direction
// is a reviewed migration. Callers must additionally establish that the rest of
// the flag's contract is unchanged before skipping the failure.
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
CommandPath: commandPath,
Flag: flag,
From: from,
To: to,
}]
return reviewed
}
// flagContractOtherwiseChanged reports whether anything checkCompatibility
// enforces about a flag, other than its type, regressed against the baseline. A
// reviewed type migration is only accepted when this returns false, so the
// exemption cannot carry an unrelated regression in behind it.
//
// Every condition here mirrors one of the checks that follow the type check in
// checkCompatibility and must stay in step with them. The pairing is anchored by
// tests that bundle each regression with the reviewed type change and require
// the type failure to reappear, so a condition that drifts out of sync fails
// loudly instead of silently widening the exemption.
func flagContractOtherwiseChanged(expected flagContract, actual *pflag.Flag, actualScope string) bool {
if expected.Shorthand != "" && actual.Shorthand != expected.Shorthand {
return true
}
if expected.RequiredSet && !expected.Required && isRequiredFlag(actual) {
return true
}
if expected.HiddenSet && !expected.Hidden && actual.Hidden {
return true
}
if expected.NoOptSet && actual.NoOptDefVal != expected.NoOpt {
return true
}
if expected.ScopeSet && expected.Scope == "persistent" && actualScope == "local" {
return true
}
return false
}
// mergedFlagContractOtherwiseChanged is the mergeContracts counterpart of
// flagContractOtherwiseChanged, comparing two recorded contracts rather than a
// contract against a live pflag definition. It mirrors the checks that follow
// the type check in mergeContracts.
func mergedFlagContractOtherwiseChanged(oldFlag, newFlag flagContract) bool {
if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand {
return true
}
if oldFlag.RequiredSet && !oldFlag.Required && newFlag.Required {
return true
}
if oldFlag.HiddenSet && !oldFlag.Hidden && newFlag.Hidden {
return true
}
if oldFlag.NoOptSet && newFlag.NoOptSet && oldFlag.NoOpt != newFlag.NoOpt {
return true
}
if oldFlag.ScopeSet && oldFlag.Scope == "persistent" && newFlag.Scope == "local" {
return true
}
return false
}
@@ -77,7 +77,7 @@
]
},
{
"id": "chat.search.cross-conversation",
"id": "chat.search.filtered",
"preferred_tool": "chat.shortcut_search_msg",
"allowed_fallbacks": [
{
+123 -2
View File
@@ -18,6 +18,18 @@ import (
const schemaContractVersion = 3
// propertySourceReviewedMappingExclusion is the provenance source a parameter
// reports when its property is deliberately omitted through the reviewed
// mapping exclusion table (internal/cli/schema_parameter_mapping_ledger.go).
// Only this source qualifies for the property-clearing carve-out in
// checkParameterCompatibility.
const propertySourceReviewedMappingExclusion = "reviewed_mapping_exclusion"
// interfaceModeMCP is the interface_mode of a tool backed by exactly one RPC.
// Only a tool that is mcp on both sides can qualify for the interface_ref
// redirect carve-out in compatibleInterfaceRefRedirect.
const interfaceModeMCP = "mcp"
type schemaContract struct {
Version int `json:"version"`
Products map[string]productSchema `json:"products"`
@@ -53,6 +65,7 @@ type positionalSchema struct {
type parameterSchema struct {
Type string `json:"type"`
Property string `json:"property,omitempty"`
PropertySource string `json:"property_source,omitempty"`
InterfaceType string `json:"interface_type,omitempty"`
Required bool `json:"required,omitempty"`
CLIRequired bool `json:"cli_required,omitempty"`
@@ -361,6 +374,11 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) {
InterfaceDefault json.RawMessage `json:"interface_default"`
Format string `json:"format"`
Enum []string `json:"enum"`
FieldProvenance struct {
Property struct {
Source string `json:"source"`
} `json:"property"`
} `json:"field_provenance"`
}
if err := json.Unmarshal(raw, &parameter); err != nil {
return parameterSchema{}, err
@@ -388,6 +406,7 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) {
return parameterSchema{
Type: parameterType,
Property: strings.TrimSpace(parameter.Property),
PropertySource: strings.TrimSpace(parameter.FieldProvenance.Property.Source),
InterfaceType: strings.TrimSpace(parameter.InterfaceType),
Required: parameter.Required,
CLIRequired: parameter.CLIRequired,
@@ -479,7 +498,6 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}{
{name: "primary_cli_path", old: oldTool.PrimaryCLIPath, new: newTool.PrimaryCLIPath},
{name: "interface_mode", old: oldTool.InterfaceMode, new: newTool.InterfaceMode},
{name: "interface_ref", old: oldTool.InterfaceRef, new: newTool.InterfaceRef},
{name: "availability", old: oldTool.Availability, new: newTool.Availability},
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
@@ -510,10 +528,84 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}
failures = append(failures, checkParameterCompatibility(toolPath, parameter, oldParameter, newParameter)...)
}
// interface_ref is evaluated last, because the redirect carve-out below is
// conditional on every other check for this tool having passed.
if oldTool.InterfaceRef != newTool.InterfaceRef &&
!compatibleInterfaceRefRedirect(toolPath, oldTool, newTool, failures) {
failures = append(failures, fmt.Sprintf("schema tool %q changed interface_ref", toolPath))
}
sort.Strings(failures)
return failures
}
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
// RPCs share business semantics, permissions, error behaviour, or side effects,
// so a redirect is only accepted when the specific tool and the specific
// old→new pair appear here. Any other ref change is still reported.
//
// Keyed by tool path ("<product>/<tool id>"), then by the previous
// interface_ref, with the value being the single accepted new ref. Both refs are
// the canonicalized interface_ref JSON exactly as parseTool produces it (see
// canonicalRawJSON) — not a bare RPC name. Adding an entry is a contract
// decision and belongs in review, not in a feature change.
var reviewedInterfaceRefRedirect = map[string]map[string]string{
// The style surface moved from update_range (which writes values) to
// set_cell_range's cellStyles payload (style-only, preserving values). This
// is the only channel that can express italic / underline / strike-through /
// font family / borders. Same product, same target range semantics, same
// permission scope; the flat style properties it loses are accepted
// separately as reviewed mapping exclusions.
"sheet/sheet.range_set_style": {
`{"product_id":"sheet","rpc_name":"update_range"}`: `{"product_id":"sheet","rpc_name":"set_cell_range"}`,
},
}
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
// backing RPC when the migration is an explicitly reviewed entry in
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
// unchanged.
//
// interface_ref is audit and traceability metadata: it records which RPC backs a
// leaf. Nothing reads it at runtime — the tool a leaf invokes is decided in the
// CLI source, so a stale ref does not misroute a call, it only misinforms a
// reader. When the backing RPC genuinely moves, the honest options are to update
// the ref or to keep publishing a name that no longer matches the request being
// sent.
//
// Being audit-only is why a reviewed redirect can be accepted at all; it is not
// a reason to accept redirects in general. Two RPCs with compatible Schema
// parameters may still differ in permissions, quota, error taxonomy, or side
// effects, none of which this gate can see. Hence the allowlist below, plus:
//
// - interface_mode is unchanged and stays "mcp". A move to or from
// "composite" is a change in kind, not a redirect, and is still reported.
// - both refs are non-empty. Removing a ref is not a redirect.
// - no other compatibility failure was recorded for this tool. This is the
// operative meaning of "the CLI contract is unchanged": no parameter was
// lost, none became required, no type / default / format / enum moved, no
// constraint tightened, no positional or dry_run change. Any one of those
// re-reports the redirect, so the exemption cannot smuggle a surface change
// in behind a backend move.
//
// A cleared property that resolved through a reviewed mapping exclusion is
// already accepted by checkParameterCompatibility and so does not block this;
// that pairing is expected, since a leaf moving to a nested payload loses its
// flat property names in the same change.
func compatibleInterfaceRefRedirect(toolPath string, oldTool, newTool toolSchema, otherFailures []string) bool {
if oldTool.InterfaceMode != newTool.InterfaceMode || newTool.InterfaceMode != interfaceModeMCP {
return false
}
if oldTool.InterfaceRef == "" || newTool.InterfaceRef == "" {
return false
}
if reviewedInterfaceRefRedirect[toolPath][oldTool.InterfaceRef] != newTool.InterfaceRef {
return false
}
return len(otherFailures) == 0
}
// compatibleAdditiveConstraintEvolution accepts constraint evolution that
// cannot invalidate an invocation expressible by the historical public
// parameter contract. Existing groups may only gain members; additions to a
@@ -730,7 +822,6 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
new string
}{
{name: "type", old: oldParameter.Type, new: newParameter.Type},
{name: "property", old: oldParameter.Property, new: newParameter.Property},
{name: "default", old: oldParameter.Default, new: newParameter.Default},
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
{name: "format", old: oldParameter.Format, new: newParameter.Format},
@@ -739,6 +830,36 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
}
}
// Clearing property is accepted as compatible only when the new value is
// omitted through a reviewed mapping exclusion. This is the same shape as
// the interface_type carve-out below: a declaration that the flag has no
// single top-level RPC property, replacing a value that is no longer true.
//
// It exists because a leaf whose backing RPC gains a nested payload has no
// honest flat property to publish. The alternative outcomes are both worse:
// keep naming a field the request no longer contains, or let assembly fall
// back to flag_name_inference and publish a name that appears in no request
// at all.
//
// The predicate is deliberately narrow:
// - old non-empty AND new empty (a redirect to a different non-empty
// value stays a contract break)
// - the new value resolved through reviewed_mapping_exclusion, so an
// accidental or silent drop is still reported
//
// The exclusion table cannot be abused to wave through arbitrary clearing:
// internal/cli/schema_parameter_bindings.go verifies that every parameter
// claiming an exclusion really does deliver an empty property, and every
// entry carries a non-empty reviewed reason.
//
// Consumers must treat a missing property as "no direct mapping" and read
// the provenance reason for where the value actually lands. Re-populating a
// property requires an explicit ParamDecl declaration.
if oldParameter.Property != newParameter.Property &&
!(oldParameter.Property != "" && newParameter.Property == "" &&
newParameter.PropertySource == propertySourceReviewedMappingExclusion) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, name))
}
// Clearing interface_type is accepted as compatible: a deliberate,
// wire-visible policy decision taken with the pinned MCP metadata
// retirement. Production no longer projects MCP-sourced types unless
+252
View File
@@ -5,6 +5,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -340,6 +341,18 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
{name: "changed property", want: "changed property", mutate: func(contract *schemaContract) {
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Property = "subject" })
}},
{name: "cleared property without a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) {
mutateParameter(contract, func(parameter *parameterSchema) {
parameter.Property = ""
parameter.PropertySource = "flag_name_inference"
})
}},
{name: "redirected property despite a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) {
mutateParameter(contract, func(parameter *parameterSchema) {
parameter.Property = "subject"
parameter.PropertySource = propertySourceReviewedMappingExclusion
})
}},
{name: "changed interface type", want: "changed interface_type", mutate: func(contract *schemaContract) {
mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceType = "integer" })
}},
@@ -679,3 +692,242 @@ func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing
t.Fatal("each historical group must match a distinct expanded group")
}
}
// Clearing a property through the reviewed mapping exclusion table is the one
// accepted shape, mirroring the interface_type retirement allowance. A leaf
// whose backing RPC moved to a nested payload has no honest flat property to
// publish; the alternatives are naming a field the request no longer contains,
// or letting flag_name_inference publish a name that appears in no request.
func TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion(t *testing.T) {
baseline := baselineContract()
// Accepted: non-empty -> empty, resolved through a reviewed exclusion.
current := cloneContract(baseline)
mutateParameter(&current, func(parameter *parameterSchema) {
parameter.Property = ""
parameter.PropertySource = propertySourceReviewedMappingExclusion
})
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
t.Fatalf("clearing property through a reviewed exclusion should pass: %v", failures)
}
// Every neighbouring shape must stay incompatible, so the carve-out cannot
// be widened by accident.
for _, tc := range []struct {
name string
property string
source string
}{
{"cleared by inference", "", "flag_name_inference"},
{"cleared by a native annotation", "", "native_annotation"},
{"cleared with no recorded source", "", ""},
{"redirected to another non-empty value", "cellStyles", propertySourceReviewedMappingExclusion},
} {
t.Run(tc.name, func(t *testing.T) {
drifted := cloneContract(baseline)
mutateParameter(&drifted, func(parameter *parameterSchema) {
parameter.Property = tc.property
parameter.PropertySource = tc.source
})
failures := checkCompatibility(baseline, drifted)
if len(failures) == 0 {
t.Fatal("must remain incompatible")
}
var sawProperty bool
for _, failure := range failures {
if strings.Contains(failure, "changed property") {
sawProperty = true
}
}
if !sawProperty {
t.Fatalf("expected a changed property failure, got %v", failures)
}
})
}
// A parameter that never published a property must not gain one silently
// just because it carries an exclusion source.
populated := cloneContract(baseline)
mutateParameter(&populated, func(parameter *parameterSchema) { parameter.Property = "" })
gained := cloneContract(populated)
mutateParameter(&gained, func(parameter *parameterSchema) {
parameter.Property = "backgroundColors"
parameter.PropertySource = propertySourceReviewedMappingExclusion
})
if failures := checkCompatibility(populated, gained); len(failures) == 0 {
t.Fatal("populating a previously empty property must stay incompatible")
}
}
// Repointing a leaf at a different backing RPC is accepted only when the
// CLI-facing contract is provably unchanged. interface_ref is audit metadata;
// nothing reads it at runtime, so a stale value misinforms a reader rather than
// misrouting a call. The gate is that no other compatibility check for the tool
// failed — that is the operative meaning of "the contract is unchanged".
// reviewedInterfaceRefRedirect 的键值必须是 parseTool 经 canonicalRawJSON 实际产出
// 的紧凑 JSON。这条守卫是必要的:该表先前误登记为裸 RPC 名("update_range"),后又
// 误登记为带空格的美化 JSON,两次都让豁免完全失效——而上面的用例是用测试自己注册
// 的值断言的,所以两次都通过了,只有真实门禁才报错。这里用 canonicalRawJSON 复算
// 期望值,把格式锚定到生产代码而不是作者的书写习惯。
func TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON(t *testing.T) {
canon := func(raw string) string {
got, err := canonicalRawJSON(json.RawMessage(raw))
if err != nil {
t.Fatalf("canonicalRawJSON(%s): %v", raw, err)
}
return got
}
if len(reviewedInterfaceRefRedirect) == 0 {
t.Fatal("redirect allowlist 为空:若确已清空,请同时删除这条守卫")
}
for toolPath, pairs := range reviewedInterfaceRefRedirect {
if len(pairs) == 0 {
t.Errorf("%s: 空的 redirect 表项没有意义", toolPath)
}
for oldRef, newRef := range pairs {
if got := canon(oldRef); got != oldRef {
t.Errorf("%s: old ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, oldRef, got)
}
if got := canon(newRef); got != newRef {
t.Errorf("%s: new ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, newRef, got)
}
if oldRef == newRef {
t.Errorf("%s: old 与 new 相同,不构成 redirect", toolPath)
}
}
}
}
func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) {
// The redirect carve-out only accepts an explicitly reviewed tool + old→new
// ref pair. Register the fixture's path for the duration of this test so the
// accepted shapes below exercise the allowlist rather than a blanket rule.
const fixturePath = "doc/doc.create"
oldRef := `{"product_id":"sheet","rpc_name":"update_range"}`
newRef := `{"product_id":"sheet","rpc_name":"set_cell_range"}`
reviewedInterfaceRefRedirect[fixturePath] = map[string]string{oldRef: newRef}
t.Cleanup(func() { delete(reviewedInterfaceRefRedirect, fixturePath) })
// The baseline fixture is interface_mode=local; the carve-out only applies to
// mcp-backed leaves, so establish an mcp baseline first.
baseline := cloneContract(baselineContract())
mutateTool(&baseline, func(tool *toolSchema) {
tool.InterfaceMode = interfaceModeMCP
tool.InterfaceRef = oldRef
})
// Accepted: mcp -> mcp, both refs non-empty, the pair is reviewed, nothing
// else changed.
redirected := cloneContract(baseline)
mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = newRef })
if failures := checkCompatibility(baseline, redirected); len(failures) != 0 {
t.Fatalf("a reviewed interface_ref redirect should pass: %v", failures)
}
// Paired with a reviewed property clearing, which is the realistic shape:
// a leaf moving to a nested payload loses its flat property names.
withCleared := cloneContract(redirected)
mutateParameter(&withCleared, func(parameter *parameterSchema) {
parameter.Property = ""
parameter.PropertySource = propertySourceReviewedMappingExclusion
})
if failures := checkCompatibility(baseline, withCleared); len(failures) != 0 {
t.Fatalf("redirect plus reviewed property clearing should pass: %v", failures)
}
// Rejected shapes. Each must still report the redirect, so a surface change
// cannot ride along behind a backend move.
for _, tc := range []struct {
name string
mutate func(*schemaContract)
}{
{"ref removed rather than redirected", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = "" })
}},
{"mode moved to composite", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) {
tool.InterfaceMode = "composite"
tool.InterfaceRef = ""
})
}},
{"mode moved away from mcp while the ref is redirected", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) {
tool.InterfaceMode = "local"
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}`
})
}},
{"a parameter became required", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Required = true })
}},
{"a parameter type moved", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Type = "integer" })
}},
{"a property was cleared without a reviewed exclusion", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
mutateParameter(contract, func(parameter *parameterSchema) {
parameter.Property = ""
parameter.PropertySource = "flag_name_inference"
})
}},
{"a confirmation gate moved", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) {
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}`
tool.Confirmation = "user_required"
})
}},
{"a parameter was dropped", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) {
tool.InterfaceRef = newRef
delete(tool.Parameters, "title")
})
}},
// The allowlist itself must be load-bearing: a redirect that is not an
// exact reviewed tool + old→new pair stays incompatible even when the rest
// of the contract is untouched. Schema shape cannot prove two RPCs share
// permissions, error taxonomy, or side effects.
{"redirect to a ref that is not the reviewed target", func(contract *schemaContract) {
mutateTool(contract, func(tool *toolSchema) {
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"some_other_rpc"}`
})
}},
} {
t.Run(tc.name, func(t *testing.T) {
drifted := cloneContract(baseline)
tc.mutate(&drifted)
failures := checkCompatibility(baseline, drifted)
var sawRef bool
for _, failure := range failures {
if strings.Contains(failure, "changed interface_ref") {
sawRef = true
}
}
if !sawRef {
t.Fatalf("expected the redirect to stay reported, got %v", failures)
}
})
}
// The allowlist must be load-bearing: the exact same old→new pair on a tool
// with no reviewed entry stays incompatible. Asserted outside the table
// because the registration has to survive until checkCompatibility runs.
t.Run("redirect on a tool absent from the reviewed allowlist", func(t *testing.T) {
saved := reviewedInterfaceRefRedirect[fixturePath]
delete(reviewedInterfaceRefRedirect, fixturePath)
defer func() { reviewedInterfaceRefRedirect[fixturePath] = saved }()
unlisted := cloneContract(baseline)
mutateTool(&unlisted, func(tool *toolSchema) { tool.InterfaceRef = newRef })
failures := checkCompatibility(baseline, unlisted)
var sawRef bool
for _, failure := range failures {
if strings.Contains(failure, "changed interface_ref") {
sawRef = true
}
}
if !sawRef {
t.Fatalf("an unlisted tool must not get the redirect carve-out, got %v", failures)
}
})
}
+197 -16
View File
@@ -234,6 +234,100 @@ def lower_count(tool: str, raw: Any) -> int | None:
return None
def distinct_lower_message_count(
raw_calls: list[tuple[str, str, Any]], tool: str
) -> int | None:
"""Count unique messages across all pages without retaining their IDs.
Time-boundary pagination may repeat the boundary message on the following
page. Summing page lengths would therefore report a false projection
mismatch for an otherwise correct de-duplicating Shortcut.
"""
metrics = distinct_lower_message_metrics(raw_calls, tool)
return metrics[0] if metrics is not None else None
def distinct_lower_message_metrics(
raw_calls: list[tuple[str, str, Any]], tool: str
) -> tuple[int, int, int] | None:
"""Aggregate count/reaction/thread facts over one stable message-ID set."""
seen: set[str] = set()
reaction_messages: set[str] = set()
thread_messages: set[str] = set()
anonymous = 0
found_collection = False
identity_keys = ("openMessageId", "openMsgId", "messageId", "msgId", "id")
for _, call_tool, raw in raw_calls:
if call_tool != tool:
continue
rows: list[Any] | None = None
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
rows = at_path(raw, path)
if rows is not None:
break
if rows is None:
continue
found_collection = True
for row in rows:
identity = ""
if isinstance(row, dict):
for key in identity_keys:
value = row.get(key)
if value not in (None, ""):
identity = f"id:{value}"
break
if not identity:
identity = f"anonymous:{anonymous}"
anonymous += 1
seen.add(identity)
if reaction_message_count(row, upper=False) > 0:
reaction_messages.add(identity)
if thread_message_count(row, upper=False) > 0:
thread_messages.add(identity)
if not found_collection:
return None
return len(seen), len(reaction_messages), len(thread_messages)
def distinct_lower_collection_count(
raw_calls: list[tuple[str, str, Any]],
tool: str,
identity_keys: tuple[str, ...],
) -> int | None:
"""Count unique rows across every lower page for one tool."""
seen: set[str] = set()
anonymous = 0
found_collection = False
for _, call_tool, raw in raw_calls:
if call_tool != tool:
continue
rows: list[Any] | None = None
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
rows = at_path(raw, path)
if rows is not None:
break
if rows is None:
continue
found_collection = True
for row in rows:
if not isinstance(row, dict):
anonymous += 1
continue
identity = ""
for key in identity_keys:
value = row.get(key)
if value not in (None, ""):
identity = str(value)
break
if identity:
seen.add(identity)
else:
anonymous += 1
if not found_collection:
return None
return len(seen) + anonymous
def response_is_error(value: Any) -> bool:
if not isinstance(value, dict):
return False
@@ -384,6 +478,41 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
upper_count = count_projection_items(capture.stdout)
expected_lower_count = lower_count(tool, raw) if tool else None
pagination_raw = raw
lower_message_projection = raw
lower_message_metrics: tuple[int, int, int] | None = None
if capture.command == "+thread-replies" and tool == "list_topic_replies":
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "list_topic_replies"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif capture.command == "+at-me" and tool == "search_at_me_message":
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "search_at_me_message"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif (
capture.command == "+messages-list-direct"
and tool == "list_individual_chat_message"
):
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "list_individual_chat_message"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif capture.command in {"+chat-list-all", "+my-groups"} and tool == (
"list_my_groups_pagination"
):
expected_lower_count = distinct_lower_collection_count(
capture.raw_calls,
"list_my_groups_pagination",
(
"openConversationId",
"openconversation_id",
"conversationId",
"openCid",
"cid",
"id",
),
)
if capture.command == "+chat-members-list":
counts = upper.get("counts", {}) if isinstance(upper, dict) else {}
upper_count = counts.get("total") if isinstance(counts, dict) else None
@@ -412,12 +541,26 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
exact_passthrough = bool(capture.raw_calls and upper == raw)
upper_message_projection = primary_projection_for_message_metrics(upper)
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
lower_reactions = reaction_message_count(raw, upper=False)
upper_threads = thread_message_count(upper_message_projection, upper=True)
lower_threads = thread_message_count(raw, upper=False)
if lower_message_metrics is not None:
_, lower_reactions, lower_threads = lower_message_metrics
else:
lower_reactions = reaction_message_count(lower_message_projection, upper=False)
lower_threads = thread_message_count(lower_message_projection, upper=False)
upper_pagination = pagination_meta(upper)
lower_pagination = pagination_meta(pagination_raw)
upper_explicitly_incomplete = (
isinstance(upper, dict)
and upper.get("complete") is False
and (
upper.get("hasMore") is True
or bool(upper.get("failures"))
or bool(upper.get("truncatedByPageLimit"))
or bool(upper.get("truncatedByResultLimit"))
)
)
if capture.timed_out:
status = "timeout"
elif capture.exit_code != 0:
@@ -426,10 +569,22 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
status = "error_envelope"
elif not capture.raw_calls:
status = "no_lower_capture"
elif exact_passthrough:
status = "pass"
elif upper_explicitly_incomplete:
status = "incomplete"
elif expected_lower_count == 0 and upper_count in (None, 0):
# An empty collection is useful evidence, but it is not a successful
# non-empty business-data E2E. This must precede exact-passthrough so a
# raw empty list cannot be promoted to pass merely because projection
# preserved it byte-for-byte.
status = "pass_empty"
elif expected_lower_count is not None and upper_count != expected_lower_count:
status = "projection_mismatch"
elif upper_count == 0:
# The upper projection is empty but the lower response shape does not
# expose a countable collection, so correctness cannot be proven.
status = "projection_unverified"
elif exact_passthrough:
status = "pass"
elif lower_reactions > 0 and upper_reactions != lower_reactions:
status = "projection_mismatch"
elif lower_threads > 0 and upper_threads != lower_threads:
@@ -439,8 +594,6 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
or not upper_pagination.get("resume_present")
):
status = "pagination_mismatch"
elif expected_lower_count == 0 and upper_count == 0:
status = "pass_empty"
else:
status = "pass"
@@ -571,6 +724,7 @@ def live_audit(
open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip()
topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip()
topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip()
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip()
media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip()
media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip()
@@ -592,17 +746,19 @@ def live_audit(
return capture
category = run("+category-list")
groups_all = run("+chat-list-all", "--limit", "200")
groups_all = run(
"+chat-list-all", "--limit", "200", "--page-all", "--page-limit", "50"
)
groups_mine = run("+chat-list-mine", "--limit", "200")
run("+conversation-list", "--limit", "100")
run("+conversation-list-top", "--limit", "100")
run("+messages-list-unread-conversations", "--count", "100")
run("+unread-chats", "--count", "100")
run("+chat-list-join-requests", "--limit", "50")
run("+at-me", "--days", "30")
run("+at-me", "--days", "30", "--page-all", "--page-limit", "50")
run("+bot-search", "--page", "1", "--size", "100")
run("+bot-find", "--query", "机器人", "--limit", "20")
run("+my-groups")
run("+my-groups", "--page-all", "--page-limit", "50")
category_id = first_string(category.upper(), "categoryId", "category_id")
if category_id:
@@ -815,6 +971,9 @@ def live_audit(
"--forward=false",
"--limit",
"20",
"--page-all",
"--page-limit",
"50",
],
timeout,
)
@@ -904,17 +1063,32 @@ def live_audit(
"topicId",
)
if topic_id:
run(
"+thread-replies",
topic_args = [
"--group",
topic_group_override or group_id,
"--topic-id",
topic_id,
"--time",
time.strftime("%Y-%m-%d %H:%M:%S"),
"--limit",
"20",
)
]
if topic_page_size:
topic_args.extend(
[
"--limit",
topic_page_size,
"--page-all",
"--page-limit",
"50",
]
)
else:
topic_args.extend(
[
"--time",
time.strftime("%Y-%m-%d %H:%M:%S"),
"--limit",
"20",
]
)
run("+thread-replies", *topic_args)
else:
results["+thread-replies"] = blocked(
"+thread-replies",
@@ -1129,6 +1303,11 @@ def main() -> int:
]
if any(topic_override) and not all(topic_override):
parser.error("topic fixture override requires both group and topic IDs")
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
if topic_page_size and (
not topic_page_size.isdigit() or int(topic_page_size) < 1
):
parser.error("DWS_AUDIT_TOPIC_PAGE_SIZE must be a positive integer")
media_override = [
os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(),
os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(),
@@ -1165,6 +1344,7 @@ def main() -> int:
failures = {
"projection_mismatch",
"projection_unverified",
"pagination_mismatch",
"backend_error",
"cli_error",
@@ -1172,6 +1352,7 @@ def main() -> int:
"timeout",
"no_lower_capture",
"local_artifact_mismatch",
"incomplete",
}
return 1 if any(result["status"] in failures for result in results) else 0
@@ -4,8 +4,12 @@
from __future__ import annotations
from run_chat_shortcut_live_audit import (
Capture,
distinct_lower_collection_count,
distinct_lower_message_count,
primary_projection_for_message_metrics,
reaction_message_count,
summarize_capture,
thread_message_count,
)
@@ -41,11 +45,245 @@ def test_non_envelope_passthrough() -> None:
check("bare list passes through", primary_projection_for_message_metrics(rows) is rows)
def test_thread_reply_pages_are_deduplicated() -> None:
raw_calls = [
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": True,
"messages": [
{
"openMessageId": "m1",
"openConvThreadId": "thread-1",
"emotionReplyList": [{"type": "LIKE"}],
"createTime": "2026-08-05 16:48:20",
}
],
}
},
),
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": False,
"messages": [
{
"openMessageId": "m1",
"openConvThreadId": "thread-1",
"emotionReplyList": [{"type": "LIKE"}],
"createTime": "2026-08-05 16:48:20",
},
{"openMessageId": "m0", "createTime": "2026-08-05 16:47:20"},
],
}
},
),
]
check(
"boundary duplicate counted once",
distinct_lower_message_count(raw_calls, "list_topic_replies") == 2,
)
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1","threadId":"thread-1",'
'"reactions":[{"type":"LIKE"}]},{"messageId":"m0"}],'
'"count":2,"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("multi-page projection passes", summary["status"] == "pass")
check("all lower calls reported", summary["lower"]["call_count"] == 2)
check("unique lower count reported", summary["lower"]["count"] == 2)
check("boundary reaction counted once", summary["lower"]["reaction_message_count"] == 1)
check("boundary thread counted once", summary["lower"]["thread_message_count"] == 1)
def test_group_pages_are_counted_across_all_lower_calls() -> None:
raw_calls = [
(
"im",
"list_my_groups_pagination",
{"result": {"groups": [{"openConversationId": "g1"}], "hasMore": True}},
),
(
"im",
"list_my_groups_pagination",
{
"result": {
"groups": [
{"openConversationId": "g1"},
{"openConversationId": "g2"},
],
"hasMore": False,
}
},
),
]
check(
"group pages deduplicated",
distinct_lower_collection_count(
raw_calls,
"list_my_groups_pagination",
("openConversationId", "conversationId", "id"),
)
== 2,
)
capture = Capture(
command="+chat-list-all",
argv=[],
exit_code=0,
stdout=(
'{"groups":[{"openConversationId":"g1"},'
'{"openConversationId":"g2"}],"count":2,'
'"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("multi-page group projection passes", summary["status"] == "pass")
check("all group rows reported", summary["lower"]["count"] == 2)
def test_thread_reply_empty_terminal_page_passes() -> None:
raw_calls = [
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": True,
"messages": [
{"openMessageId": "m1", "createTime": "2026-08-05 16:48:20"}
],
}
},
),
("chat", "list_topic_replies", {"result": {"hasMore": False, "messages": []}}),
]
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1"}],"count":1,'
'"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("empty terminal page passes", summary["status"] == "pass")
check("first-page item retained", summary["lower"]["count"] == 1)
def test_empty_projection_is_not_promoted_to_non_empty_pass() -> None:
capture = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[("bot", "search_my_robots", {"result": {"robots": []}})],
duration_ms=1,
)
summary = summarize_capture(capture)
check("empty projected collection is explicit", summary["status"] == "pass_empty")
dropped_projection = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[
("bot", "search_my_robots", {"result": {"robots": [{"robotId": "r1"}]}})
],
duration_ms=1,
)
summary = summarize_capture(dropped_projection)
check("non-empty lower projection cannot pass empty", summary["status"] == "projection_mismatch")
unknown_lower_shape = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[("bot", "search_my_robots", {"result": {"unexpected": []}})],
duration_ms=1,
)
summary = summarize_capture(unknown_lower_shape)
check("unknown lower collection is not a success", summary["status"] == "projection_unverified")
no_upper_count = Capture(
command="+unread-chats",
argv=[],
exit_code=0,
stdout='{"result":[]}',
stderr="",
raw_calls=[
(
"chat",
"unread_message_conversation_list",
{"result": {"conversations": []}},
)
],
duration_ms=1,
)
summary = summarize_capture(no_upper_count)
check(
"lower-confirmed empty collection without upper count is explicit",
summary["status"] == "pass_empty",
)
def test_incomplete_projection_is_not_promoted_to_pass() -> None:
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1"}],"count":1,'
'"complete":false,"hasMore":true,"truncatedByPageLimit":true,'
'"pagesFetched":1,"nextPage":{"time":"2026-08-05T08:48:19.136Z"}}'
),
stderr="",
raw_calls=[(
"chat",
"list_topic_replies",
{"result": {"hasMore": True, "nextCursor": 1785919699136,
"messages": [{"openMessageId": "m1"}]}},
)],
duration_ms=1,
)
summary = summarize_capture(capture)
check("bounded partial read is explicit", summary["status"] == "incomplete")
def main() -> None:
tests = [
test_compatibility_aliases_are_counted_once,
test_projection_fallback_order,
test_non_envelope_passthrough,
test_thread_reply_pages_are_deduplicated,
test_group_pages_are_counted_across_all_lower_calls,
test_thread_reply_empty_terminal_page_passes,
test_empty_projection_is_not_promoted_to_non_empty_pass,
test_incomplete_projection_is_not_promoted_to_pass,
]
for test in tests:
print(f"{test.__name__}:")
+16 -1
View File
@@ -142,12 +142,27 @@ omit_global:
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
# Byte-identical pairs (content surfaces that must stay in sync).
# Reviewed file pairs. Most are byte-identical; layout-only link differences
# must be declared as data-driven substitutions.
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
- mono: references/products/whiteboard/recipes.md
multi: dingtalk-misc/references/whiteboard/recipes.md
mode: identical
# Directory pairs whose relative file sets and contents must stay byte-identical.
paired_trees:
- mono: references/products/sheet
multi: dingtalk-misc/references/sheet
mode: identical
# Scripts under skills/multi/*/scripts not referenced by any .md in that skill tree.
# yida/finance/aiapp are referenced via dingtalk-misc/references/unsupported-scripts.md
orphan_scripts_allowlist:
+4 -1
View File
@@ -15,6 +15,7 @@
| "这个 alidocs 表格链接帮我看下"(粘贴原始 URL) | 先 probe 节点类型 | `dws doc info --node` → 按 `extension` 路由 | 直接调 `sheet` | `alidocs/i/nodes/{id}` 可能是文档/axls/able/xlsx 等,禁止凭 URL 猜类型 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive download --node` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格,必须下载后本地解析 |
| "把这个在线表格导出为 xlsx 文件" | 在线表格格式转换 | `dws sheet export` | `dws drive download` | `export` 是 axls → xlsx 的导出转换;`download` 只能下载已有的 xlsx 节点 |
| "把这个 html 文件放到钉钉/导入 html 页面" | HTML 文件上钉 | `drive upload`(钉盘)或 `doc import`(文档空间/知识库,自动改走上传链路) | `doc read + doc create` 重写链 | `doc import` 对白名单外格式(html/pdf/zip 等)不报错,自动改走文件上传原样入库(stderr 有改道提示);用户明确要"可编辑在线文档"时先把 HTML 转 md 再 import |
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` / `aitable` | 个人待办提醒,非文档内容或数据表 |
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive upload` | — | 文件上传是存储层操作,归 drive |
@@ -45,6 +46,7 @@
| "查看这条话题的所有回复" | 话题回复 | `chat +thread-replies` | `chat message list-topic-replies` | 已知 threadId/topicId 时自动投影回复人、正文和时间 |
| "把这些消息里的附件下载下来" | 消息资源下载 | 查询 Shortcut 的 `--download-resources` | `resourceRefs.shortcut` 返回的下载命令 | 先用查询结果的 resourceRefs;单资源再执行返回的可运行参数 |
| "消息发没发成功/查询消息发送状态" | 查询消息发送状态 | `chat message query-send-status` | — | 需要 send 返回的 openTaskId |
| "编辑/撤回我刚发的消息" | 发后编辑/撤回 | `send` → `query-send-status` → `edit` / `recall` | 按消息内容反查 | 使用 send 返回的 openTaskId 直接获取两个后续 ID |
| "撤回我发的消息/撤回消息" | 撤回个人消息 | `chat message recall` | `chat message recall-by-bot` | recall 撤回个人消息,recall-by-bot 撤回机器人消息 |
| "未读消息会话/未读会话列表/我的未读会话" | 未读会话列表 | `chat message list-unread-conversations` | `chat message read-status` | list-unread-conversations 查哪些会话有未读;read-status 查具体消息的已读状态 |
| "谁看了这条消息/消息已读未读/查读状态" | 查询消息已读状态 | `chat message read-status` | `chat message list-unread-conversations` | read-status 查具体消息的已读人员;list-unread-conversations 查未读会话列表 |
@@ -311,6 +313,7 @@ dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type
用 `chat message query-send-status` 的场景:
- "消息发没发成功/查询消息发送状态" — 查询个人发送消息的状态,需要 send 返回的 openTaskId
- 发送成功时返回 openMessageId 和 openConversationId,可直接用于 edit/recall
用 `chat +search-msg` 的场景(推荐首选):
- "按发送者搜索消息/指定多个群搜索/@我的消息多维度搜" — 支持关键词、发送者、@我、@指定人、多个会话等维度组合搜索
@@ -324,7 +327,7 @@ dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type
上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。输出只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
**不支持的场景**:
- "撤回我刚发的消息"(但不知道消息 ID) — 需先通过消息拉取或搜索接口(如 `chat message list`、`chat message search-advanced` 等)获取 openMessageId,再调用 `chat message recall`
- "撤回历史消息"(且已丢失发送返回的 openTaskId) — 通过消息拉取或搜索接口获取 openMessageId,再调用 `chat message recall`
判断关键:普通发送统一走 `+messages-send` 并显式选择 `--as`;个人撤回→ `recall`;机器人撤回→ `recall-by-bot`;查发送状态→ `query-send-status`;消息搜索类意图优先路由到 `+search-msg`,仅在不适用时降级到原子命令。
+24 -6
View File
@@ -653,6 +653,8 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
#### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态
查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。
发送成功时返回 openMessageId 和 openConversationId,可直接用于后续编辑或撤回。
```
Usage:
dws chat message query-send-status [flags]
@@ -666,6 +668,23 @@ Flags:
- openTaskId 由 `dws chat message send` 发送消息成功后返回
- 用于确认消息是否已成功发送或获取发送失败的原因
- 返回结果中含发送成功消息的 openMessageId,可用于后续 recall(撤回)、read-status(查已读)等命令
- 返回结果同时含 openConversationId;与 openMessageId 组合后可直接用于 edit(编辑)或 recall(撤回)
```
发送后编辑/撤回时,优先使用下列 ID 链,无需按消息内容反查:
```bash
# 1. 发送后保留 openTaskId
dws chat message send --group <openConversationId> --text "原始内容"
# 2. 查询得到 openMessageId 和 openConversationId
dws chat message query-send-status --open-task-id <openTaskId>
# 3. 编辑消息
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
# 发送后撤回使用同一 ID 链
dws chat message send --group <openConversationId> --text "待撤回的内容"
dws chat message query-send-status --open-task-id <openTaskId>
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
```
#### 撤回消息 — 撤回当前用户自己发出的消息
@@ -676,15 +695,13 @@ Usage:
dws chat message recall [flags]
Example:
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMessageId (必填)
注意:
- --conversation-id 的别名: --group, --id, --chat (均可替代 --conversation-id)
- 消息 ID 可通过 `dws chat message list` 命令获取
- 刚由 `chat message send` 发出的消息,使用 `query-send-status` 返回的 openConversationId 和 openMessageId;只有历史消息或已丢失 openTaskId 时才通过消息拉取/搜索获取 ID
- 仅支持撤回当前用户以个人身份发出的消息,不能撤回他人发送的消息,也不能撤回机器人发出的消息
- 与 `recall-by-bot` 的区别:本命令通过 IM 接口撤回用户自己发出的消息(需要 openConversationId + openMessageId),`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 robot-code + processQueryKey)
```
@@ -1411,11 +1428,11 @@ Usage:
dws chat message list-favorites [flags]
Example:
dws chat message list-favorites
dws chat message list-favorites --size 50
dws chat message list-favorites --size 30
dws chat message list-favorites --cursor 20 --size 20
Flags:
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
--size int 一次拉取的收藏数量,默认 20,范围 1-100
--size int 一次拉取的收藏数量,默认 20,范围 1-30
注意:
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
@@ -2373,7 +2390,8 @@ Flags:
| `chat bot find` | `botOpenDingTalkId` | 给机器人发单聊消息(send --open-dingtalk-id;字段名是 botOpenDingTalkId,非 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id |
| `chat message list` | `openMessageId` | recall 的 --msg-id |
| `chat message query-send-status` | `openMessageId` + `openConversationId` | 刚发送消息的 edit / recall |
| `chat message list` | `openMessageId` | 历史消息或已丢失 openTaskId 时的 recall |
| `chat message search` | `nextCursor` | 下次 message search 的 --cursor |
| `chat message search-advanced` | `nextCursor` | 下次 message search-advanced 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
+2 -1
View File
@@ -142,7 +142,7 @@ Flags:
### 导入本地文件为在线文档
详见 [doc/doc-import.md](./doc/doc-import.md)。
详见 [doc/doc-import.md](./doc/doc-import.md)。白名单外格式(html/pdf/zip/无扩展名等)不报错,自动改走文件上传原样入库,JSON 结果带 `fallback=upload`、`converted=false` 标记。
```
Usage:
@@ -671,6 +671,7 @@ Flags:
用户说"导入文件/导入为在线文档/导入 Word/导入 Excel/导入 xmind/导入 Markdown/把本地文件转在线文档":
- 导入并转换为在线文档 → `doc import --file <本地路径>`
- 支持 docx/doc/xlsx/xls/md/txt/xmind/mark,文件大小不超过 20MB
- 白名单外格式(html/pdf/zip/无扩展名等)不报错:自动改走文件上传链路原样入库(stderr 有改道提示),JSON 结果带 `fallback=upload`、`converted=false` 标记,产出的是文件对象而非在线文档,不得报告为"已转换";要"可编辑在线文档"时先把内容转为 md 再导入
- 如果用户指定知识库或文件夹,补充 `--workspace` 或 `--folder`
- 不要把本地文件内容先读出来再 `doc create/update`;应直接使用 `doc import`
@@ -31,6 +31,8 @@ dws doc import get --task-id <TASK_ID> --format json
支持格式:docx、doc、xlsx、xls、md、txt、xmind、mark。文件大小上限 20MB。
不支持转换的格式(html/pdf/zip/无扩展名等所有白名单外格式)不报错:CLI 自动改走文件上传链路,以原文件形式存入 `--folder`/`--workspace` 指定的文档空间/知识库位置(同样受 20MB 上限约束),stderr 会输出改道提示,JSON 结果带 `fallback=upload`、`converted=false` 标记——表示产出的是文件对象而非在线文档,Agent 不得将其报告为"已转换"。若用户要的是"可编辑的在线文档",先把内容转换为 md,再用 md 文件执行 `doc import`;若目标是钉盘,直接用 `dws drive upload`。
## 工作流
1. 确认本地文件存在且格式受支持。
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,8 @@
`batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / 工作表。规划 range 时先确认每个子操作的边界。
2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地预先计算的预期值对照。
2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,单元格值用 `range read` 或 `csv-get` 抽样回读受影响区域;合并用裸 `sheet info`,行列分组用 `sheet info --include groups` 回读校验。
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `batch-update` 补齐,禁止交付半成品。
4. **统一确认语义**:`range batch-clear` 和 `batch-update` 都可删除内容或结构;先用 `--dry-run` 预览,真实执行必须获得用户确认后追加 `--yes`。
## 使用场景
@@ -23,17 +22,28 @@
- 原子事务:任一操作失败则整批回滚
- 传 `--continue-on-error` 可切换为宽松模式(失败继续)
用户说"批量写入多个结构化 table/跨 sheet 写入 dataframe/table":
- 不使用 `batch-update`;直接调用 `dws sheet table-put --node <NODE_ID> --sheets '{"sheets":[...]}'`
- `table-put` 自身支持一次写入多个 sheet spec,写入后用 `table-get` 回读 dtype / format / 数据行数
用户说"批量创建行列分组/批量取消行列分组/同时调整尺寸并分组":
- 连续行/列分组 → `group-dimension`
- 取消连续行/列分组 → `ungroup-dimension`
- 分组回读用 `sheet info --include groups`
- `group-dimension` 在 batch 中只适合默认展开分组;需要创建后立即折叠时,用独立 `dws sheet group-dimension --group-state fold`
**何时推荐使用 `batch-update`**:
- 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容)
- 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize)
- 需要对**多个**连续行/列范围执行 `group-dimension` / `ungroup-dimension` 时(默认展开分组)
- 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`)
- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`)
- 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合)
当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
**不可放进 `--operations` 的操作**(强行写入会被校验拒或行为未定义):
- `range read` / `csv-get`(只读操作,不属于写入)
- `range read` / `csv-get` / `table-get`(读取操作,不在 batch dispatch 表中;结构化 table 回读需在 batch 外单独调用)
- `table-put`(当前 `batch-update` 不支持结构化 table 写入;请直接调用独立 `dws sheet table-put`)
- `range sort` / `range move-to`(尚未支持,后续版本补充)
- `write-image` / `media-upload` / `create-float-image` / `update-float-image`(需本地上传或依赖前置上传句柄)
- `set-style` / `range batch-set-style`(自身已是批量入口,不可再嵌套)
@@ -50,9 +60,9 @@
Usage:
dws sheet range batch-clear [flags]
Example:
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]' --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:Z1000"]' --type all --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]'
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:Z1000"]' --type all
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format
Flags:
--node string 表格文档 ID 或 URL (必填)
--ranges string 目标区域 JSON 数组,每项带 sheet 前缀 (必填)
@@ -72,9 +82,10 @@ Example:
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}}
]' --yes
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]' --yes
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}},
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}
]'
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--operations string 操作数组 JSON (必填,每项 {toolName, input})
@@ -91,7 +102,8 @@ Notes:
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
- 真实执行必须获得用户确认后加 --yes;--dry-run 不需要 --yes
- `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension`
- `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put`
```
### 子操作定位规则
@@ -107,6 +119,8 @@ Notes:
]
```
`table-put` / `table-get` 不支持放进 `batch-update`。结构化 table 写入请直接调用 `dws sheet table-put`,写入完成后在 batch 外单独调用 `table-get` 回读校验。
## 校验与预览
### Validate 阶段
@@ -121,27 +135,10 @@ CLI 在发送请求前执行以下本地校验(不消耗网络请求):
| 每项必须是 object | `operations[N] 不是 object` | `--operations '["string"]'` |
| `toolName` 必须是支持的 CLI 命令名 | `unsupported toolName "xxx"` | 传 `"batch-update"`(禁止嵌套)或拼写错误 |
| 禁止嵌套 `batch-update` | 同上(`batch-update` 不在 dispatch 表中,自动拦截) | `toolName: "batch-update"` |
| 禁止 `table-put` / `table-get` | `unsupported toolName "table-put"` | 结构化 table 应使用独立 `table-put` |
> `input` 内的字段(如 `sheet-id`、`range`、`values` 等)由服务端校验,CLI 不提前拦截——传入空值或缺失必填字段时,请求会到达服务端再返回错误。
### DryRun 阶段
加 `--dry-run` 预览翻译后的完整 MCP 请求,不实际执行任何操作:
```bash
dws sheet batch-update --node NODE_ID --dry-run --operations '[...]'
```
DryRun 输出内容:
- `Tool: batch_update`(最终调用的 MCP 工具名)
- `Arguments:` 完整 JSON(含翻译后的 `operations` 数组,展示每个子操作的 MCP toolName + 参数名 + 参数值)
- 若传了 `--continue-on-error`,输出 JSON 中会包含 `"continueOnError": true`
DryRun 适用场景:
- 确认 CLI 命令名 → MCP toolName 的翻译是否正确
- 确认参数名转换(如 `--range` → `rangeAddress`)是否符合预期
- 检查 `csv-put` 的 `@filepath` 是否被正确解析为文件内容,并确认 `=...` / `'=...` 字段原样进入子操作
### --continue-on-error 行为
| 模式 | flag | 子操作失败时 | 返回格式 |
@@ -149,7 +146,7 @@ DryRun 适用场景:
| 严格事务(默认) | 不传 | 整批回滚到初始状态,后续子操作不执行 | 顶层 `error` 对象 |
| 宽松模式 | `--continue-on-error` | 已执行的子操作不回滚,继续执行后续子操作 | `results` 数组 |
**严格事务失败的错误信息**由服务端(flex-table-app)生成,包含以下内容:
**严格事务失败的错误信息**由服务端生成,包含以下内容:
- 失败操作索引:`operations[N]`(0-based,即 `--operations` JSON 数组中的下标)
- 回滚告知:"All previously executed operations have been rolled back."
- 失败原因:原始错误码和错误消息
@@ -177,13 +174,13 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
| `merge-cells` | [sheet-style-format](./sheet-style-format.md) |
| `unmerge-cells` | [sheet-style-format](./sheet-style-format.md) |
| `update-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `range fill` | [sheet-range-operations](./sheet-range-operations.md) |
| `range copy-to` | [sheet-range-operations](./sheet-range-operations.md) |
| `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
| `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
| `csv-put` | [sheet-write-data](./sheet-write-data.md) |
@@ -237,32 +234,44 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
]
```
### 批量创建行列分组
### 批量创建/取消行列分组
分组属于工作表结构元数据,批次完成后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`,不要用裸 `sheet info` 或 `range read` 校验。回读项中的 `level` 是 1-based,`collapsed` 表示当前折叠状态。
```jsonc
[
{"toolName":"group-dimension",
"input":{"sheet-id":"...","range":"3:7","group-state":"expand"}},
{"toolName":"group-dimension",
"input":{"sheet-id":"...","range":"C:F"}},
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"10:12"}}
"input":{"sheet-id":"...","range":"C:F","group-state":"expand"}}
]
```
```jsonc
[
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"3:7"}},
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"C:F"}}
]
```
注意:batch 中不要用 `group-state:"fold"` 来创建折叠分组;需要折叠初始状态时,用独立 `dws sheet group-dimension --group-state fold`。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | batch-clear 的 --ranges sheet 前缀 / batch-update 子操作 input 的 sheet-id |
| `batch-update` | 各子操作的执行结果 | 回读校验(range read / csv-get) |
| `batch-update` | 各子操作的执行结果 | 回读校验(值用 range read / csv-get;合并用 sheet info;分组用 sheet info --include groups) |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **需要对多个区域执行相同清除操作时,用 `range batch-clear`**:一次原子请求清除多个区域(可跨工作表),失败时整批回滚
- ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品
- ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照
- ★ **table 写入不进 batch-update**:当前 `batch-update` 不支持结构化 table 写入;结构化 table 请直接调用 `dws sheet table-put`
- ★ **批次完成后必须回读校验**:值写入用 `range read` 或 `csv-get` 抽样回读受影响区域;结构变更用 `sheet info` 回读
- ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update`
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F`
- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold`
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;分组结果用 `sheet info --include groups` 回读
- `batch-update` 中 `group-dimension` 只适合默认展开分组;需要 `fold` 初始状态时使用独立 `group-dimension`
@@ -34,6 +34,7 @@
| "百分比面积" | 百分比堆积面积图(areaPercentStacked) | 占比式面积 |
| "散点图"、"相关性" | 散点图(scatter) | 两变量相关性 |
| "雷达图"、"多维度" | 雷达图(radar) | 多维度对比 |
| "柱+线"、"组合图"、"双轴" | 组合图(combo) | 多种图表叠加,支持副轴 |
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
@@ -55,8 +56,6 @@ Flags:
--chart-id string 浮动图表 ID (可选,不传则返回全部)
```
- **判空看 `totalCount` / `message`,不要看数组长度**:无图表时 `floatCharts` 数组里仍会带 1 个全 null 的幽灵元素(`chart.category`/`chart.series` 全 null,无 id),但 `totalCount` 为 0、`message` 为「Successfully retrieved 0 float chart(s).」。判断有没有图表以 `totalCount` 为准,或过滤掉没有 `id` 的元素。
### 创建浮动图表
```
Usage:
@@ -130,7 +129,7 @@ Flags:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `row` | number | 是 | 锚点行索引(0-based),如 0 表示第一行 |
| `col` | string | 是 | 锚点列。使用字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列),不支持数字形式 |
| `col` | string/number | 是 | 锚点列。支持字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列)或 0-based 数字(`0`=第1列) |
**offset 字段**(可选,不传时默认 0):
@@ -150,9 +149,10 @@ Flags:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` |
| `series` | object[] | 是 | 数据系列数组,至少一项,详见下方 series 字段表 |
| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`) |
| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` / `combo` |
| `series` | object[] | 非 combo 必填 | 数据系列数组,至少一项,详见下方 series 字段表。combo 类型不使用此字段 |
| `subCharts` | object[] | combo 必填 | 组合图子图数组,至少一项,详见下方 subCharts 字段表。仅 combo 类型使用 |
| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`)。combo 类型在 subCharts 内声明 |
| `title` | object | 否 | 图表标题配置,详见下方 title 字段表 |
| `legend` | object | 否 | 图例配置,详见下方 legend 字段表 |
| `catAx` | object | 否 | 分类轴(X轴)样式配置,详见下方轴配置表 |
@@ -165,6 +165,15 @@ Flags:
| `name` | string | 否 | 系列名称的单元格引用,A1 表示法(如 `"B1"`)。不传时图例显示为"系列1"等默认名 |
| `value` | string[] | 是 | 系列数据区域,A1 表示法字符串数组(如 `["B2:B10"]`)。每个元素为一段连续区域 |
**subCharts 数组项**(仅 combo 类型):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | 是 | 子图类型,仅允许笛卡尔坐标系类型:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked`。不允许 pie/doughnut/scatter/radar/combo |
| `series` | object[] | 是 | 子图数据系列,格式同上方 series 数组项 |
| `category` | string[] | 是 | 子图分类轴数据区域(笛卡尔坐标系必须提供) |
| `useSecondaryAxis` | boolean | 否 | 是否使用副轴(右侧 Y 轴),默认 false |
**title 字段**:
| 字段 | 类型 | 必填 | 说明 |
@@ -199,7 +208,7 @@ Flags:
- 格式:`"A2:B10"`(默认使用 `--sheet-id` 对应的工作表)
- 跨 sheet 引用:`"SHEET_ID!A2:B10"`(前缀为工作表 ID)
- 单元格引用:`"B1"`(等同于 `"B1:B1"`)
- MCP 内部自动将 A1 引用转换为 ISheetRange 结构,CLI 直接透传即可
- 直接传 A1 表示法字符串即可,命令会处理成图表所需的区域引用
## 图表位置选择(创建前必做)
@@ -277,32 +286,19 @@ dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
}
}'
# ── 工作流 4: 多图表组合分析 ──
# 当用户需要"柱+线"等多种图表组合分析时,分别创建对应类型的图表:
# ── 工作流 4: 创建组合图(combo)──
# 当用户需要"柱+线"叠加、双轴对比时,使用 combo 类型:
# 创建柱形图
dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
"position": {"row": 40, "col": "A"},
"dimensions": {"width": 700, "height": 450},
"dimensions": {"width": 800, "height": 500},
"chart": {
"type": "column",
"series": [{"name": "B1", "value": ["B2:B10"]}],
"category": ["A2:A10"],
"title": {"show": true, "text": "销售额"},
"legend": {"show": true, "pos": "t"}
}
}'
# 创建折线图
dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
"position": {"row": 40, "col": "J"},
"dimensions": {"width": 700, "height": 450},
"chart": {
"type": "line",
"series": [{"name": "C1", "value": ["C2:C10"]}],
"category": ["A2:A10"],
"title": {"show": true, "text": "增长率"},
"legend": {"show": true, "pos": "t"}
"type": "combo",
"subCharts": [
{"type": "column", "series": [{"name": "B1", "value": ["B2:B10"]}], "category": ["A2:A10"]},
{"type": "line", "series": [{"name": "C1", "value": ["C2:C10"]}], "category": ["A2:A10"], "useSecondaryAxis": true}
],
"title": {"show": true, "text": "销售额与增长率"}
}
}'
```
@@ -344,10 +340,10 @@ dws sheet chart update --node <NODE_ID> --sheet-id <SHEET_ID> --chart-id <CHART_
- [强制] **`--sheet-id` 获取规范**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- [强制] **创建后必须验证**:图表创建后必须调用 `chart list` 验证配置是否正确
- ⚠️ **`chart list` 判空看 `totalCount` 不看数组长度**:无图表时 `floatCharts` 仍含 1 个全 null 幽灵元素(无 id),`totalCount` 为 0、`message` 说明为空。判断有没有图表以 `totalCount` 为准或过滤无 `id` 的元素
- [强制] **chart-id 禁止臆测**:必须通过 `chart list` 获取真实的图表 ID,不可编造
- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut`
- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut`,"柱+线/组合/双轴"用 `combo`
- **堆积类型后缀**:堆叠版本的图表在基本类型后加 `Stacked`(如 `columnStacked`、`lineStacked`、`areaStacked`)
- **combo 组合图**:不用顶层 `series`/`category`,改用 `chart.subCharts` 数组,每个子图独立声明 `type`/`series`/`category`,可选 `useSecondaryAxis: true` 指定副轴。子图 type 仅允许笛卡尔坐标系类型(column/bar/line/area 及其 Stacked 变体),不允许 pie/doughnut/scatter/radar/combo
- **位置不得压在数据区上**:图表浮层会遮挡原始数据,必须放在空白区域
- **多图表需求**:多次调用 `chart create`,不要试图用一个图表包含所有分析
- **跨 sheet 引用数据**:在 A1 区域前加 `SHEET_ID!` 前缀,如 `"sheetXXX!A2:B10"`
@@ -0,0 +1,166 @@
# sheet comment(表格单元格评论:list / create / reply / update / delete)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../sheet.md`](../sheet.md) — 命令路由 + 场景索引 + 意图判断 + 全局约束
>
> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ [`sheet-workbook.md`](sheet-workbook.md)(未知工作表名称时先 `list` 确认真实名称)
---
## 适用范围
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
---
## sheet comment list(查询表格评论列表)
```
Usage:
dws sheet comment list [flags]
Example:
dws sheet comment list --node <SHEET_ID>
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved
dws sheet comment list --node <SHEET_ID> --cursor <TOKEN>
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
--sheet-id string 工作表 ID 或名称,如 Sheet1(与 --range 一起指定时按单元格过滤)
--range string 单元格位置,A1 表示法,如 A2、B5:C10(与 --sheet-id 一起指定时按单元格过滤)
```
- 同时传入 `--sheet-id` 和 `--range` 时,仅返回该单元格的评论(服务端按单元格精确过滤);不传则返回表格全部单元格评论。
---
## sheet comment create(创建单元格评论)
```
Usage:
dws sheet comment create [flags]
Example:
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题"
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请核实" --mention uid1,uid2
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--sheet-id string 工作表 ID 或名称,如 Sheet1 (必填)
--range string 单元格位置,A1 表示法,仅支持单个单元格,如 A2 (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
- 未知工作表名称时,先 `dws sheet list --node <SHEET_ID> --format json` 确认真实名称,禁止臆测 `Sheet1` / `0` / `default`。
---
## sheet comment reply(回复评论)
```
Usage:
dws sheet comment reply [flags]
Example:
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实"
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
- 回复自动归属到被回复评论所在的单元格线程,无需再传 `--sheet-id` / `--range`。
---
## sheet comment update(更新评论)
```
Usage:
dws sheet comment update [flags]
Example:
dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--content string 更新后的评论文字内容,纯文本 (必填)
```
---
## sheet comment delete(删除评论)
> [强制] 危险操作:删除不可恢复。必须先向用户展示操作摘要并获得明确同意,用户同意后才加 `--yes` 执行。
```
Usage:
dws sheet comment delete [flags]
Example:
dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
```
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` 返回中提取,用于 `reply` / `update` / `delete`。
- `create` / `list`(按单元格过滤)的 `--sheet-id` 是工作表 ID 或名称;未知时先 `dws sheet list` 确认,禁止臆测。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `delete` 是不可逆操作;AI Agent 必须先向用户展示操作摘要并获得明确同意,同意后才追加 `--yes`。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create` 的 `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| [`sheet-workbook.md`](sheet-workbook.md) `sheet list` 的工作表名称 | `comment create/list` 的 `--sheet-id` |
| `dws aisearch person` 的 `userId` | `comment create/reply` 的 `--mention` |
## 常用模板
```bash
# 查看表格全部单元格评论
dws sheet comment list --node <SHEET_ID> --format json
# 仅看某个单元格的评论
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2 --format json
# 仅看未解决的评论
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved --format json
# 在单元格上创建评论(未知工作表名先 sheet list 确认)
dws sheet list --node <SHEET_ID> --format json
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题" --format json
# 创建评论 + @人(先 aisearch person 拿 userId)
dws aisearch person --keyword "张三" --dimension name --format json
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请确认" --mention <uid1>,<uid2> --format json
# 文字回复
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实" --format json
# 表情回复(--content 填表情名称)
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 更新评论
dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
# 删除评论(不可逆;必须用户确认后再加 --yes)
dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes --format json
```
## 参考
- [`../sheet.md`](../sheet.md)(如何路由到本命令族)
- [`./sheet-workbook.md`](sheet-workbook.md)(取工作表名称)
- `dws aisearch person`(取 mention 用的 userId,跨产品命令)
@@ -64,14 +64,13 @@
**正确做法(两步走)**:
```bash
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列)
# 每个单元格必须是 object,公式写在 text(以 = 开头);不支持裸字符串
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \
--values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],...]'
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列,以 H2:H4 为例)
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H4" \
--values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A3>B3,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A4>B4,\"是\",\"否\")"}]]'
# Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列)
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--ranges '["A2:H4"]' \
--condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
@@ -143,8 +142,7 @@ Flags:
- **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。
- **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。
- **返回**:`rules` 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
- **判空要看 `message`,不要看数组长度**:无规则时 `rules` 数组里仍会带 1 个全 null 的幽灵元素(`colorScaleCondition`/`dataBarStyle`/`iconSetCondition` 全 null,无 id),`message` 会是「No conditional formatting rules found.」。该命令**没有 `totalCount` 字段**,判空以 message 为准,或过滤掉没有 `id` 的元素。
- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
### 创建条件格式规则
```
@@ -271,4 +269,3 @@ Flags:
- **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过
- **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入
- **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规
- ⚠️ **`cond-format list` 判空看 `message` 不看数组长度**:无规则时 `rules` 仍含 1 个全 null 幽灵元素(无 id),`message` 为「No conditional formatting rules found.」。该命令无 `totalCount`,判空以 message 为准或过滤无 `id` 的元素
@@ -14,7 +14,7 @@
用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性":
- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false`
- 设置行高/列宽 → `update-dimension --pixel-size`
- 设置行高/列宽 → `update-dimension --pixel-size`;恢复默认尺寸 → `--size-type standard`;行高按内容自适应 → `--size-type auto`(列宽不提供 auto)
- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden`
用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行":
@@ -26,11 +26,12 @@
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组":
- 创建分组 → `group-dimension --range <整行或整列范围>`
- 创建并折叠 → `group-dimension --group-state fold`
- 取消分组 → `ungroup-dimension --range <整行或整列范围>`
- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`
用户说"创建行分组/创建列分组/新建分组并折叠/新建分组并展开/取消分组":
- 创建连续行/列分组 → `group-dimension`
- 取消连续行/列分组 → `ungroup-dimension`
- 分组创建后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`
- 仅创建分组时可用 `--group-state expand|fold` 指定初始展开/折叠;当前没有单独调整已有分组折叠状态的命令
- 用户要求"折叠/展开已有分组"时,当前不支持直接修改已有分组的 `collapsed` 状态,不能承诺可用 `group-dimension` 完成
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
@@ -116,6 +117,12 @@ Example:
# 使用工作表前缀(忽略 --sheet-id)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden
# 恢复第 2~3 行为默认行高(无需 --pixel-size)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "2" --length 2 --size-type standard
# 第 2~3 行按内容自适应行高
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "2" --length 2 --size-type auto
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
@@ -124,14 +131,25 @@ Flags:
--length string 更新数量,正整数 (必填),最大 5000
--hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一
--pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一
--size-type string 尺寸模式: pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)
```
批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。
`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。
支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--hidden` 与 `--pixel-size` 至少必须提供一个(或用 `--size-type standard/auto` 让服务端决定尺寸)。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。
`--size-type` 尺寸模式(对齐飞书 row_sizes/col_sizes 的 type):
| 值 | 行为 | ROWS | COLUMNS | 是否需要 --pixel-size |
|----|------|------|---------|----------------------|
| `pixel`(默认) | 使用 `--pixel-size` 指定的像素值 | 支持 | 支持 | 需要 |
| `standard` | 恢复为工作表默认行高/列宽 | 支持 | 支持 | 不需要 |
| `auto` | 按内容自适应行高 | 支持 | 不提供 | 不需要 |
列宽不提供 `auto`(与飞书 `col_sizes` 的 type 枚举一致,只有 `pixel` / `standard`)。需要控制列宽时用 `--pixel-size` 指定,或用 `--size-type standard` 恢复默认列宽。
### 移动行或列
```
Usage:
@@ -183,32 +201,51 @@ Flags:
Usage:
dws sheet group-dimension [flags]
Example:
# 创建第 3~7 行分组,默认展开
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
# 创建 C~F 列分组,并在创建后折叠
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F" --group-state fold
# 使用工作表前缀(忽略 --sheet-id)
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!3:7"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
--group-state string 创建后的分组状态: expand 或 fold,默认 expand
--range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F"
--group-state string 创建后的分组状态: expand 或 fold (默认 expand)
```
为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。
创建连续整行或整列分组。`--range` 只能是整行范围或整列范围,不支持普通单元格矩形(如 `A1:C5`)。支持单行/单列(如 `"3:3"` / `"C:C"`)以及带工作表前缀(如 `Sheet1!3:7`),带前缀时忽略 `--sheet-id`。
返回中使用 `level` 表示分组展示层级,`level` 为 1-based;不要查找或依赖 `depth`。`--group-state fold` 只用于创建后的初始折叠状态,当前不提供"仅修改已有分组 collapsed 状态"的独立命令。
创建后用以下命令回读:
```bash
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --include groups --format json
```
回读字段为 `rowGroups` / `columnGroups`,单项包含 `range`、起止行列、`count`、`level`、`collapsed`。`collapsed=true` 表示当前分组折叠。
### 取消行或列分组
```
Usage:
dws sheet ungroup-dimension [flags]
Example:
# 取消第 3~7 行分组
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
# 取消 C~F 列分组
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
--range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F"
```
取消指定连续行或连续列的分组。
取消指定连续整行或整列范围上的分组。取消后同样用 `sheet info --include groups` 回读确认目标 `range` 已从 `rowGroups` / `columnGroups` 中移除。
`group-dimension` / `ungroup-dimension` 可以放进 `batch-update` 做原子组合;但 batch 中的 `group-dimension` 只适合默认展开分组。需要创建后立即折叠时,请使用独立 `dws sheet group-dimension --group-state fold`。
## 核心工作流
@@ -282,15 +319,15 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 |
| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 |
| `group-dimension` | `range` / `level` / `groupState` | 确认创建的分组范围、层级和初始状态 |
| `ungroup-dimension` | `range` / `level` | 确认取消分组的范围和层级 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`;如需"先插入行列再写入数据"的组合操作,使用 [batch-update](./sheet-batch-operations.md) 打包为原子事务
- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `insert-dimension` 的 `--length` 最大为 5000
@@ -313,7 +350,8 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5`
- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand`
- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold`
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,不接受普通单元格矩形
- `group-dimension` 输出和 `sheet info --include groups` 回读均使用 `level`,且为 1-based;不要使用旧的 `depth` 字段
- `sheet info --include groups` 是分组回读入口;裸 `sheet info` 不承诺返回行列分组,`range read` / `csv-get` 不返回行列分组
- 当前不能直接调整已有分组的 `collapsed` 状态;`--group-state fold` 只在创建分组时生效
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`,但不适合创建后立即折叠;需要 `fold` 时使用独立 `group-dimension`
@@ -55,11 +55,10 @@ Flags:
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
```
查询指定范围内的下拉列表配置信息,包括选项值和颜色。
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `multipleValues` 和 `colorValueMap`)。**判空以 `hasDropdown` 为准**:范围内无下拉列表时 `hasDropdown` 为 false,但 `dataValidations` 里仍会带 1 个全 null 的幽灵条目,不要用数组长度判空。
- ⚠️ **已知限制**:`options.multipleValues` 服务端恒返回 `null`(即使该下拉是用 `--multi-select` 建的),也不返回 `enableMultiSelect` 字段——**`get-dropdown` 读回无法判断该下拉是否多选**。要判断是否多选,改用 `range read`:它返回的 `cells[].dataValidation.enableMultiSelect` 字段是准确的(实测有效)。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -91,6 +90,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。判空以 `hasDropdown` 为准(无下拉时为 false,但 `dataValidations` 仍含 1 个全 null 幽灵条目)
- ⚠️ `get-dropdown` 的 `options.multipleValues` 恒为 `null`、无 `enableMultiSelect` 字段,读回无法判断是否多选;要判断是否多选改用 `range read`(其 `dataValidation.enableMultiSelect` 准确)
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -11,6 +11,11 @@
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式
- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理
## 命令详细参考
### 导出表格为 xlsx(异步任务一站式)
@@ -47,17 +52,60 @@ Flags:
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
**命令返回**(`--format json`,默认):输出规整 JSON `{success, jobId, downloadUrl[, outputPath]}`。轮询进度以 `[INFO] [N/30] 状态: ...` 打到 stderr,不污染 stdout 的 JSON(可直接 `python3 -m json.tool` 解析 stdout)。
- `--output` 未指定:JSON 含 `jobId` + `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径,JSON 额外含 `outputPath`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名保存,JSON 含 `outputPath`
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
### 导出单个工作表为纯 CSV(同步)
```
Usage:
dws sheet export-csv [flags]
Example:
dws sheet export-csv --node <NODE_ID>
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv
dws sheet export-csv --node <NODE_ID> --range A1:Z1000 --value-render-option raw_value
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称(不传则第一个工作表)
--range string 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
--value-render-option string 取值模式: formatted_value(默认) / raw_value / formula
--output string 本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout
--allow-truncated 允许数据被截断时仍然导出。默认截断即报错且不写文件
```
同步读取**单个**工作表并输出 RFC4180 CSV,不走异步导出任务。与 `export` 是两条独立命令:`export` 导整篇工作簿的 xlsx(异步提交+轮询),`export-csv` 只导一个工作表的纯值(一次请求即返回)。不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理。
**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。`--output` 指向已存在目录时保存为该目录下的 `sheet-export.csv`。
**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式:
- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …)
- 改用 `dws sheet export` 导出完整表格的 xlsx
- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格"
```bash
# 落盘到本地
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv
# 输出到 stdout 便于管道处理
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID>
# 大表分块导出(避免截断;不分块时默认会因截断而报错)
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:Z1000" --output ./part1.csv
# 明确接受不完整数据(否则截断即失败)
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --output ./partial.csv
```
注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用 `dws sheet export` 导 xlsx。只是让 Agent 读取内容(带 `[row=N]` 行号前缀)请用 `dws sheet csv-get`。
## 核心工作流
```bash
@@ -83,6 +131,8 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
@@ -93,3 +143,5 @@ dws sheet export --node <NODE_ID> --output ./
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -219,7 +219,6 @@ Flags:
- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。
- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。
- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。
- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。
### 列出筛选视图所有列条件
```
@@ -238,7 +237,6 @@ Flags:
- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。
- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。
- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。
- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。
### 获取单列筛选条件
```
@@ -261,7 +259,6 @@ Flags:
- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。
- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。
- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。
## 核心工作流
@@ -328,7 +325,7 @@ dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id`
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),按 `--filter-view-id` 返回对应视图
- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}`
- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示
- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置
@@ -32,7 +32,7 @@ Flags:
- **用途**:查看当前工作表上是否存在全局筛选及其配置。
- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。
- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。
- **返回**:`range`(筛选范围,A1 表示法)、`id`(筛选 ID)和 `criteria`(各列条件对象,key 为列偏移量;无条件时为 `{}`)。如果未设置筛选,返回筛选信息为空。
- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。
### 创建筛选
```
@@ -152,7 +152,7 @@ Flags:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter get` | `range`(筛选范围)、`id`、`criteria`(各列条件对象,无条件为 `{}`) | 查看当前筛选配置,确认筛选是否存在 |
| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 |
| `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 |
| `filter delete` | 删除成功的确认 | 确认筛选已删除 |
| `filter update` | 更新成功的确认 | 确认条件已设置 |
@@ -164,7 +164,7 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)、`id` 和 `criteria`(各列条件对象,无条件时为 `{}`)。无筛选时返回空
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空
- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错
- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件
- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复
@@ -0,0 +1,192 @@
# 公式写入、回读与错误校验
## 使用场景
用户说"写公式/计算列/辅助列/总计/占比/增长率/查找计算/自动计算/校验公式/检查公式错误"时使用本页。
- 能由表内其他单元格推导的派生值,优先写公式,不要写一次性的静态结果。
- 写公式前先读表头和 3-5 行样本,确认列含义、数据类型、真实行号和目标范围。
- 用户明确要求"辅助列"时,需要真实写入辅助列公式;不要只用条件格式或本地计算绕过。
## 当前能力边界
- 写少量或需要单元格对象的公式:使用 `dws sheet range update`。
- 从 CSV/表格文本批量写公式:使用 `dws sheet csv-put`,字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号。
- 公式载体:公式写在 cell object 的 `text` 字段中,例如 `{"type":"text","text":"=SUM(B2:B10)"}`。
- 读取公式文本:使用 `dws sheet range read --value-render-option formula`。
- 读取计算结果:使用 `dws sheet range read --value-render-option raw_value` 或默认 `formatted_value`。
- 聚合错误校验:使用 `dws sheet formula-verify`,支持整本表格、单个目标和多个目标。
- `formula-verify` 扫描已经落表的公式计算结果,按 `#ERROR!` / `#NAME?` / `#DIV/0!` 等错误类型汇总;它不判断一个正常数值是否符合业务预期。
- `append` / `table-put` 不作为公式写入协议;需要公式时用 `range update` 或 `csv-put`。
## 命令选择
| 目的 | 命令 | 说明 |
|------|------|------|
| 写入少量或中等范围公式 | `range update` | `--values` 必须是二维 cell object,维度与 `--range` 完全一致 |
| 从 CSV/表格文本批量写公式 | `csv-put` | `=` 开头按公式;前导单引号写入以 `=` 开头的字面文本;不支持富格式对象 |
| 查看已写入的公式文本 | `range read --value-render-option formula` | 确认公式本身是否落表、范围和引用是否正确 |
| 查看公式计算结果 | `range read --value-render-option raw_value` | 用于数值对账、错误值检查 |
| 查看格式化展示结果 | `range read` 或 `csv-get` 默认模式 | 用于用户肉眼看到的展示值检查 |
| 扫描整本表格公式错误 | `formula-verify --node <NODE_ID>` | 不传目标时扫描全部工作表的非空范围 |
| 扫描单个工作表或范围 | `formula-verify --sheet-id ... [--range ...]` | `--range` 只传 A1 范围,不带工作表前缀 |
| 扫描多个目标 | `formula-verify --targets ...` | 必须是非空数组;每项为 `{"sheetId":"...","range":"..."}`,`range` 可省略 |
## 推荐流程
1. 用 `dws sheet list --node <NODE_ID> --format json` 获取真实 `sheetId`。
2. 用 `range read` 或 `csv-get` 读取表头和样本数据,确认目标列与行号。
3. 明确相对引用和绝对引用:向下填充时检查固定汇率、税率、查找表、标题行是否需要 `$` 锁定。
4. 按数据形态写入公式:精确 cell object 用 `range update`,CSV/表格文本用 `csv-put`。`range update` 的矩阵行列数必须与 `--range` 完全一致。
5. 用 `range read --value-render-option formula` 回读公式文本,确认实际公式、范围和引用。
6. 对本次写入目标运行 `formula-verify`;若返回 `partial` / `hasMore=true`,缩小目标或提高 `--max-cells` 后继续扫描,直到结果完整。
7. 用 `range read --value-render-option raw_value` 抽样对账业务数值;正常数值不会被 `formula-verify` 判定为业务计算错误。
8. 若发现错误,先定位依赖单元格、空值、除数为 0、引用范围越界或函数名错误,再重写公式并重新执行文本回读、错误扫描和数值抽样。
## 聚合式公式校验
### 整本表格
不指定 `--sheet-id`、`--range` 或 `--targets` 时,扫描整本表格的全部工作表:
```bash
dws sheet formula-verify --node <NODE_ID> --format json
```
### 单个工作表或范围
`--sheet-id` 支持工作表 ID 或名称;省略 `--range` 时扫描该工作表的非空范围:
```bash
dws sheet formula-verify --node <NODE_ID> --sheet-id <SHEET_ID> --format json
dws sheet formula-verify --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "D2:D100" --format json
```
`--range` 必须和 `--sheet-id` 一起使用,且只传 `D2:D100` 这类 A1 范围,不能传 `Sheet1!D2:D100`。
### 多个目标
```bash
dws sheet formula-verify --node <NODE_ID> \
--targets '[{"sheetId":"Sheet1","range":"D2:D100"},{"sheetId":"Summary"}]' \
--format json
```
`--targets` 也支持 `@targets.json` 和 `-`(stdin)。数组必须至少包含一个目标,不能传 `[]`;每项只允许非空 `sheetId` 和可选的字符串 `range`,`range` 只写 A1 范围且不能带工作表前缀。使用 `--targets` 时不能再传 `--sheet-id` 或 `--range`。
### 扫描限制与自动化
```bash
dws sheet formula-verify --node <NODE_ID> \
--max-locations-per-error 20 --max-cells 30000 --format json
dws sheet formula-verify --node <NODE_ID> --exit-on-error --format json
```
- `--max-locations-per-error` 只限制每类错误返回的 `locations` 和 `samples` 数量,`count` 与 `totalErrors` 仍保留实际扫描到的总数。
- `--max-cells` 是本次调用跨全部 targets 共享的扫描预算;预算不足时返回 `status=partial`、`hasMore=true`。
- `--exit-on-error` 适合 CI/自动化:发现公式错误时打印 JSON 结果并返回非 0;`partial` 结果中已经发现错误时同样返回非 0。
### 结果判定
| 字段 | 含义 |
|------|------|
| `status` | `success` / `errors_found` / `partial` |
| `totalErrors` | 实际扫描到的错误公式单元格总数 |
| `totalFormulas` | 实际扫描到的公式单元格总数 |
| `scannedCells` | 实际扫描的单元格数 |
| `hasMore` | `true` 表示结果不完整,不能据此声称目标范围零错误 |
| `errorSummary` | 按错误类型聚合的 `count`、`locations` 和 `samples` |
| `warningMessage` | `partial` 等情况下的扫描限制提示 |
判定规则:
- `status=success`、`hasMore=false`、`totalErrors=0`:本次目标范围未发现公式错误。
- `status=errors_found`:按 `errorSummary` 修复后重新校验。
- `status=partial` 或 `hasMore=true`:当前结果不完整;缩小 targets/range 或提高 `--max-cells` 后继续校验。
## 写入示例
### 单格公式
```bash
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2" \
--values '[[{"type":"text","text":"=B2*C2"}]]' --format json
```
### 整列公式
```bash
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2:D5" \
--values '[
[{"type":"text","text":"=B2*C2"}],
[{"type":"text","text":"=B3*C3"}],
[{"type":"text","text":"=B4*C4"}],
[{"type":"text","text":"=B5*C5"}]
]' --format json
```
### 含绝对引用
税率在 `G1` 时,向下填充应锁定税率单元格:
```bash
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "E2:E5" \
--values '[
[{"type":"text","text":"=D2*$G$1"}],
[{"type":"text","text":"=D3*$G$1"}],
[{"type":"text","text":"=D4*$G$1"}],
[{"type":"text","text":"=D5*$G$1"}]
]' --format json
```
## 公式文本与结果回读
### 1. 回读公式文本
```bash
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2:D5" \
--value-render-option formula --format json
```
检查点:
- `value` 应返回以 `=` 开头的公式文本。
- 行号、列号、相对引用、绝对引用应与写入计划一致。
- 无公式的单元格在 `formula` 模式下可能回退为原始值,不能把这种回退误判为公式已写入。
### 2. 回读计算结果
```bash
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2:D5" \
--value-render-option raw_value --format json
```
检查点:
- 数值结果应与样本手算或本地复算一致。
- 检查结果中是否出现 `#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`。
- 对大范围公式,至少抽样检查首行、末行、边界行和异常数据行;用户要求全量处理时,应分批回读并断言处理数量。
### 3. 数值正确性边界
`formula-verify` 负责聚合已经落表的公式错误值;`range read` 负责确认实际公式文本和具体计算结果。即使 `formula-verify` 返回 `success`,也仍需对金额、比例、汇率、边界行等关键业务结果做 `raw_value` 抽样对账,因为一个公式可能计算出合法数值但业务逻辑仍然写错。
## 常见错误
- 想用 `csv-put` 写入 `=SUM(...)` 文本却忘记加前导单引号,导致内容被解析为公式。
- 用原始二维数组 `--values '[["=B2*C2"]]'`,而不是 cell object。
- 写整列公式时只写第一行,忘记把 `--range` 和 `--values` 扩成同样行数。
- 复制公式时没有锁定固定引用,例如税率、汇率、查找表范围。
- 没有回读 `formula` 模式,只看写入返回 `success`。
- 只回读展示值,不运行 `formula-verify` 聚合扫描错误。
- 把 `max-locations-per-error` 误解为错误计数上限;它只截断位置和样本。
- 看到 `status=partial` 或 `hasMore=true` 仍声称整本表公式零错误。
- 在 `--range` 中传 `Sheet1!A1:D10`,或把 `--targets` 与 `--sheet-id` 混用。
- 显式传空的 `--targets '[]'`;这不是“无目标”,应改为至少一个目标,整本扫描则完全省略 `--targets`。
## 关联文档
- [sheet-write-data](./sheet-write-data.md):`range update` 的 `--values` cell object 结构、维度校验、富格式能力。
- [sheet-read-data](./sheet-read-data.md):`value-render-option` 的 `formatted_value` / `raw_value` / `formula` 读取模式。
- [sheet-conditional-format](./sheet-conditional-format.md):条件格式中的 `formulaCondition` 与辅助列公式的职责边界。
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、OSS 上传、确认转换和轮询,Agent 不要自行拆分或重试
- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
@@ -71,6 +71,6 @@ dws sheet import get --task-id <TASK_ID> --format json
## 边界
- 本命令接受的是本地文件路径;它把 xlsx/xls 转换成新的 axls 在线表格
- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `doc download` 下载,需要在线编辑时再执行 `sheet import create`
- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `drive download` 下载,需要在线编辑时再执行 `sheet import create`
- 向已有在线表格写数据应使用 `range update`、`append`、`csv-put` 或 `table-put`
- md/doc/docx 等文字文档导入使用 `dws doc import`
@@ -11,7 +11,7 @@
用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格":
- 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径)
- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令
- 禁止使用 `range update` 写入图片;图片对象必须使用 `write-image` 命令
- 用户指定了图片尺寸 → `write-image --width N --height M`
### 浮动图片
@@ -48,8 +48,6 @@ Flags:
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
```
`--format json` 输出规整 JSON:`{success, resourceId, resourceUrl, fileName, fileSize}`。`resourceUrl` 可用于 `create-float-image` 的 `--src`。
### 上传图片并写入表格单元格
```
Usage:
@@ -205,10 +203,13 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range C3:C3 --fil
# 4. 完整流程: 创建表格 → 写表头 → 写入图片
dws sheet create --name "产品目录" -f json
# 提取 nodeId 后:
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A1:B1" --values '[[{"type":"text","text":"产品名称"},{"type":"text","text":"产品图片"}]]' -f json
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A2:A2" --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
# 提取 nodeId 后,先用 list 获取真实 sheetId:
dws sheet list --node <NODE_ID> -f json
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
--values '[[{"type":"text","text":"产品名称"},{"type":"text","text":"产品图片"}]]' -f json
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
```
## 上下文传递
@@ -229,7 +230,7 @@ dws sheet write-image --node <NODE_ID> --sheet-id Sheet1 --range B2:B2 --file ./
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
@@ -1,67 +1,292 @@
# 透视表管理
# 透视表 (pivot-table)
## 真对象硬约束
当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `pivot-table create` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `csv-put` 在原表中拼一张"看起来像透视表的汇总表"来代替——静态公式无法随源数据动态更新,且失去交互能力。判断标准:交付后 `pivot-table list` 必须能返回该对象。
## 使用场景
- 用户要按部门、月份等维度汇总数据 → `pivot-table create`
- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list`
- 用户要调整行列、聚合方式或显示选项 → `pivot-table update`
- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认)
读写透视表对象。本 reference 覆盖 4 个命令:
透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。
| 操作需求 | 使用命令 | 说明 |
|---------|---------|------|
| 查看已有透视表 | `pivot-table list` | 获取透视表的结构、数据源和配置 |
| 创建透视表 | `pivot-table create` | 创建透视表对象 |
| 更新透视表 | `pivot-table update` | 更新透视表配置(行/列/值/筛选字段) |
| 删除透视表 | `pivot-table delete` | 删除透视表 |
## 列出与查看
典型工作流:先读取现有透视表了解配置 -> 执行创建/更新/删除 -> 再次读取验证结果。
```bash
dws sheet pivot-table list --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet pivot-table list --node <NODE_ID> --sheet-id <SHEET_ID> --pivot-table-id <PIVOT_TABLE_ID>
## 行/值字段映射(创建前必做)
创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**:
- **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别
- **values(值字段)** = 聚合指标,即"统计什么数值"。例:销售额(`summarize_by: "sum"`)、订单数(`summarize_by: "count"`)
- **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别
| 用户说 | rows | values | columns |
|--------|------|--------|---------|
| "按部门统计人数" | 部门 | 姓名(`"count"`) | -- |
| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"`) | -- |
| "各部门男女人数" | 部门 | 姓名(`"count"`) | 性别 |
**常见配置错误(必须注意)**:
- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误
- **行列字段选择要匹配用户意图**:用户说"按商品统计金额" -> 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反
- **聚合类型要匹配**:用户说"统计数量" -> `"count"`;"统计总额" -> `"sum"`;"统计平均" -> `"average"`
- **创建后必须验证**:调用 `pivot-table list` 确认透视表结构正确
## 命令详细参考
### 获取透视表
```
Usage:
dws sheet pivot-table list [flags]
Example:
# 列出所有透视表
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID
# 获取单个透视表详情
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--pivot-table-id string 透视表 ID (可选,不传则返回全部)
```
不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。
### 创建透视表
```
Usage:
dws sheet pivot-table create [flags]
Example:
# 按部门统计销售额(默认自动新建工作表存放)
dws sheet pivot-table create --node NODE_ID \
--source "'Sheet1'!A1:D100" \
--properties '{
"rows": [{"field": "部门"}],
"values": [{"field": "销售额", "summarize_by": "sum"}],
"show_row_grand_total": true
}'
## 创建
# 指定放置到已有工作表的特定位置
dws sheet pivot-table create --node NODE_ID \
--source "'Sheet1'!A1:E200" \
--target-sheet-id TARGET_SHEET_ID --target-position "A1" \
--properties '{
"rows": [{"field": "部门"}],
"values": [{"field": "销售额", "summarize_by": "sum"}]
}'
# 通过文件传入配置
dws sheet pivot-table create --node NODE_ID \
--source "'Sheet1'!A1:D50" --properties @pivot.json
Flags:
--node string 表格文档 ID 或 URL (必填)
--source string 数据源区域,A1 表示法含 sheet 前缀 (必填,如 "'Sheet1'!A1:D100")
--properties string 透视表配置 JSON (必填,含 rows/columns/values/filters)
--target-sheet-id string 目标工作表 ID 或名称 (可选,不传则自动新建工作表)
--target-position string 透视表放置位置 (可选,A1 格式单个 cell,如 "B5",不传默认 A1)
```
### 更新透视表
```
Usage:
dws sheet pivot-table update [flags]
Example:
# 先获取现有配置
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID
# 修改后回写
dws sheet pivot-table update --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID \
--properties '{
"rows": [{"field": "部门"}],
"values": [
{"field": "销售额", "summarize_by": "sum"},
{"field": "订单号", "summarize_by": "count", "display_name": "订单数"}
],
"show_row_grand_total": true
}'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--pivot-table-id string 透视表 ID (必填,可通过 pivot-table list 获取)
--properties string 透视表配置 JSON (必填)
```
### 删除透视表
```
Usage:
dws sheet pivot-table delete [flags]
Example:
dws sheet pivot-table delete --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--pivot-table-id string 透视表 ID (必填,可通过 pivot-table list 获取)
```
> [强制] 危险操作:删除不可恢复。必须先向用户展示操作摘要并获得明确同意,用户同意后才加 `--yes` 执行。
## `--properties` JSON Schema 速查
**顶层字段**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `rows` | object[] | 否 | 行字段数组(分组维度),详见下方「rows/columns 字段项」表 |
| `columns` | object[] | 否 | 列字段数组(交叉维度),结构同 rows |
| `values` | object[] | 是 | 值字段数组(聚合指标,至少一项),详见下方「values 字段项」表 |
| `filters` | object[] | 否 | 筛选字段数组,详见下方「filters 字段项」表 |
| `show_row_grand_total` | boolean | 否 | 是否显示行总计,默认 true |
| `show_col_grand_total` | boolean | 否 | 是否显示列总计,默认 true |
| `show_subtotals` | boolean | 否 | 是否显示分类小计,默认 true |
| `repeat_row_labels` | boolean | 否 | 是否显示重复项标签,默认 false |
| `collapse` | object | 否 | 行字段折叠状态:字段名 -> 要折叠的项目列表,如 `{"部门": ["A组", "B组"]}` |
**rows/columns 字段项**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 |
| `display_name` | string | 否 | 显示名称(不传时使用 field 值) |
**filters 字段项**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 |
| `display_name` | string | 否 | 显示名称(不传时使用 field 值) |
**values 字段项**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 |
| `summarize_by` | string | 否 | 聚合方式,默认 sum,详见下方枚举表 |
| `display_name` | string | 否 | 显示名称(不传时自动生成,如"求和 - 销售额") |
| `show_data_as` | string | 否 | 值显示方式:`normal`/`percent_of_row_total`/`percent_of_col_total`/`percent_of_grand_total` |
**summarize_by 枚举值**:
| 值 | 说明 |
|----|------|
| `sum` | 求和(默认) |
| `count` | 计数 |
| `average` | 平均值 |
| `max` | 最大值 |
| `min` | 最小值 |
| `product` | 乘积 |
| `count_numbers` | 数值计数 |
| `std_dev` | 标准偏差 |
| `std_dev_p` | 总体标准偏差 |
| `var` | 方差 |
| `var_p` | 总体方差 |
| `distinct` | 去重计数 |
| `median` | 中位数 |
## `--source` 数据源格式
- 格式:`'SheetName'!StartCell:EndCell`
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
## 高级功能示例
```bash
# 含折叠 + show_data_as
dws sheet pivot-table create --node <NODE_ID> \
--source "'Data'!A1:D100" \
--source "'Sheet1'!A1:E200" \
--properties '{
"rows":[{"field":"部门"}],
"columns":[{"field":"月份"}],
"values":[{"field":"销售额","summarize_by":"sum"}],
"show_row_grand_total":true
"rows": [{"field": "部门"}],
"values": [
{"field": "销售额", "summarize_by": "sum"},
{"field": "订单数", "summarize_by": "count", "show_data_as": "percent_of_col_total"}
],
"collapse": {"部门": ["A组"]},
"show_row_grand_total": true
}'
```
可选参数:
> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
- `--target-sheet-id`:将透视表放到指定工作表;不传时由服务端创建目标工作表。
- `--target-position`:目标起始单元格,例如 `B2`。
- `--properties @pivot.json`:从文件读取配置。
`--source` 必须包含工作表前缀并覆盖表头和数据,例如 `"'Data'!A1:D100"`。创建时 `properties.values` 必须是非空数组。
## properties 结构
- `rows` / `columns` / `filters`:字段数组,每项至少包含非空 `field`。
- `values`:聚合字段数组,每项至少包含 `field`;创建时至少一项。
- `summarize_by`:`sum`、`count`、`average`、`max`、`min`、`product`、`count_numbers`、`std_dev`、`std_dev_p`、`var`、`var_p`、`distinct`、`median`。
- `collapse`:字段折叠配置,可为对象或数组。
- 显示选项:`show_row_grand_total`、`show_col_grand_total`、`show_subtotals`、`repeat_row_labels`。
## 更新
## 核心工作流
```bash
dws sheet pivot-table update --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> \
--properties '{"show_subtotals":false}'
# -- 工作流 1: 创建简单分组汇总 --
# 1. 先查 sheetId
dws sheet list --node <NODE_ID> -f json
# 2. 查看数据范围确认列名和边界
dws sheet csv-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:F5"
# 3. 创建透视表(按部门统计销售额)
dws sheet pivot-table create --node <NODE_ID> \
--source "'Sheet1'!A1:D100" \
--properties '{
"rows": [{"field": "部门"}],
"values": [{"field": "销售额", "summarize_by": "sum"}],
"show_row_grand_total": true
}'
# 4. 验证创建结果(用 create 返回的 targetSheetId 查询)
dws sheet pivot-table list --node <NODE_ID> --sheet-id <TARGET_SHEET_ID>
# -- 工作流 2: 多维度交叉分析 --
dws sheet pivot-table create --node <NODE_ID> \
--source "'Sheet1'!A1:E200" \
--properties '{
"rows": [{"field": "部门"}, {"field": "产品"}],
"columns": [{"field": "季度"}],
"values": [
{"field": "销售额", "summarize_by": "sum"},
{"field": "订单号", "summarize_by": "count", "display_name": "订单数"}
],
"show_row_grand_total": true,
"show_col_grand_total": true,
"show_subtotals": true
}'
# -- 工作流 3: 更新透视表配置 --
# 先获取现有配置
dws sheet pivot-table list --node <NODE_ID> --sheet-id <SHEET_ID> --pivot-table-id <PT_ID>
# 修改后回写(增加一个值字段)
dws sheet pivot-table update --node <NODE_ID> --sheet-id <SHEET_ID> --pivot-table-id <PT_ID> \
--properties '{
"rows": [{"field": "部门"}],
"values": [
{"field": "销售额", "summarize_by": "sum"},
{"field": "利润", "summarize_by": "average", "display_name": "平均利润"}
],
"show_row_grand_total": true
}'
```
Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。
## 上下文传递
## 删除
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `pivot-table list` | `pivotTables[].id` | 后续 update / delete 的 `--pivot-table-id` |
| `pivot-table list --pivot-table-id` | 完整配置(rows/columns/values/filters/collapse/options) | update 时作为基础配置修改后回写 |
| `pivot-table create` | `pivotTable.id` | 后续 update / delete 的 `--pivot-table-id` |
| `pivot-table create` | `pivotTable.targetSheetId` | 后续 list / update / delete 的 `--sheet-id`(透视表所在工作表) |
| `pivot-table delete` | `message` | 确认删除完成 |
| `sheet list` | 工作表的 `sheetId` | 所有 pivot-table 命令的 `--sheet-id` |
```bash
dws sheet pivot-table delete --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> --yes
```
## 注意事项
删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。
- [强制] **`--sheet-id` 获取规范**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- [强制] **创建后必须验证**:透视表创建后必须调用 `pivot-table list` 验证配置是否正确
- [强制] **pivot-table-id 禁止臆测**:必须通过 `pivot-table list` 获取真实的透视表 ID,不可编造
- [强制] **source 必须精确**:数据源范围必须从表头行开始,精确覆盖数据区域,先用 `csv-get` 确认数据边界
- **field 名称必须准确**:rows/columns/values/filters 中的 field 值必须与源数据表头完全一致(区分大小写)
- **透视表自动新建子表**:创建的透视表默认放置在自动新建的子表中,不会覆盖源数据。可通过 `--target-sheet-id` 和 `--target-position` 指定放置到已有工作表
- **不支持修改数据源**:update 仅可修改字段配置和显示选项,不可修改 source
- **折叠状态**:collapse 字段用于控制行字段的展开/折叠,格式为 {字段名: [要折叠的项]}
- **大 JSON 用 @file**:`--properties` 支持 `@文件路径` 读取本地 JSON 文件
@@ -22,6 +22,11 @@
- 自动填充 → `range fill`
- 请勿用 `range read` 读取源数据后手动计算规律再 `range update` 写入,`range fill` 支持服务端智能填充
用户说"批量清除/批量操作/一次执行多个写操作/原子批量/先清除再写入":
- 批量清除多个区域 → `range batch-clear`
- 组合多个不同写操作 → `batch-update`
- 详见 [sheet-batch-operations](./sheet-batch-operations.md)
用户说"复制区域/把这块数据复制到/复制到另一个工作表":
- 复制区域 → `range copy-to`
- 跨工作表 → `range copy-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
@@ -82,11 +87,9 @@ Flags:
--sheet-id string 工作表 ID 或名称 (必填)
--source-range string 源数据范围,A1 表示法 (必填)
--target-range string 目标填充范围,A1 表示法 (必填)
--fill-type string 填充类型: 不传则自动检测 / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值)
--fill-type string 填充类型: series(序列,默认) / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值)
```
`--fill-type` **不传时自动检测**(根据源数据智能判断:数值序列递增、日期递增、文本复制等),这是填序列的默认行为,无需显式传值。`--help` 枚举的可选值只有 `copy` / `onlystyle` / `withoutstyle`;服务端另外也接受 `series`(强制按序列填充),但它不在 `--help` 列表里,一般用不到——要序列递增直接不传 `--fill-type` 即可。
目标范围须与源范围在行或列维度对齐(不支持对角填充)。
### 复制区域
@@ -144,6 +147,8 @@ Flags:
|------|-------------|------|
| `list` | 工作表的 `sheetId` | range clear / range sort / range fill / range copy-to / range move-to 的 --sheet-id |
> **批量操作**(`range batch-clear` / `batch-update`)已拆分至 [sheet-batch-operations](./sheet-batch-operations.md),含写入边界 + 回读校验规范、典型组合场景示例等。
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
@@ -153,3 +158,4 @@ Flags:
- ★ **排序用 `range sort` 不用 `range read` + 客户端排序 + `range update`**:服务端原子操作,支持多级排序
- ★ **排序前必须 `range read` 前几行判断表头**:读取排序范围前 3-5 行,对比首行与后续行的数据模式(类型、语义)来判断是否有表头。禁止不读就排,表头被排入数据不可撤销
- ★ **填充用 `range fill` 不用 `range read` + 手动计算 + `range update`**:服务端智能填充,支持序列递增、公式扩展等
- ★ **批量操作详见 [sheet-batch-operations](./sheet-batch-operations.md)**:`range batch-clear` 多区域清除、`batch-update` 组合写操作,均原子事务
@@ -3,43 +3,34 @@
## 使用场景
用户说"读数据/看表格内容":
- 需要列名、二维数据、dtype 和 number format,或准备与 `table-put` 配套处理 → `table-get`
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
- 需要 dataframe/table 结构(列名、二维 data、pandas-style dtypes、number formats)→ `table-get`
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
- 需要校验公式写入结果 → 先 `range read --value-render-option formula` 回读公式文本,再用 `formula-verify` 聚合扫描错误;需要确认业务数值时用 `raw_value` 抽样对账。完整流程见 [sheet-formula](./sheet-formula.md)
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
## 命令选择
| 读取目的 | 推荐命令 | 说明 |
|---------|---------|------|
| DataFrame 风格结构化读取 | `table-get` | 返回 columns / data / dtypes / formats,可与 table-put 配套 |
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
| 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 |
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
| 查看单元格级超链接 | `range read` | 返回 per-cell 结构,含 hyperlink;富文本片段链接仍在 richText 内 |
| 查看公式文本 | `range read --value-render-option formula` | value 返回公式 |
| 公式写后结果校验 | `range read --value-render-option formula` + `formula-verify` + `raw_value` | 先确认公式文本,再聚合扫描错误,最后按需抽样对账业务数值 |
| 获取原始值(数字/布尔而非格式化字符串) | `range read --value-render-option raw_value` | value 返回原始类型 |
| 查看合并单元格范围 | `sheet info` | 返回 `mergedRanges`,这是工作表结构信息,不属于单元格值读取 |
## 与现有读取能力的区别
- `csv-get` 面向快速浏览和大表纯值读取:返回 CSV 文本、真实行号/列号映射和截断标记,token 低;不表达列类型、number format 或 per-cell 元数据
- `table-get` 面向 dataframe/table 数据交换:返回 `columns` / `data` / `dtypes` / `formats`,适合后续结构化分析、类型保持和 `table-put` 回写;不返回合并单元格、dataValidation、hyperlink、richText、per-cell `cellStyles`
- `range read` 面向精确单元格读取:返回二维 per-cell 对象,可看公式、原始值、dataValidation、hyperlink、richText、cellStyles;适合少量或中等范围的细节检查,但大范围读取 token 成本更高
## 命令详细参考
### 读取结构化 table 数据
```
Usage:
dws sheet table-get [flags] # 别名: dws sheet table-read
Example:
dws sheet table-get --node <NODE_ID>
dws sheet table-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D20"
dws sheet table-get --node <NODE_ID> --sheet-id <SHEET_ID> --no-header
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称
--range string 读取范围,A1 表示法;可带 sheet 前缀
--no-header 首行不作为表头,自动生成 col1/col2/...
```
`table-get` 面向结构化数据处理,返回 `columns`、二维 `data`、`dtypes` 和可用时的 `formats`。未传 `--sheet-id` / `--range` 时由服务端读取目标文档中的 table 数据;大表仍应通过 `--range` 限定范围。需要单元格样式、公式、数据验证或富文本细节时,继续使用 `range read`。
### 以 CSV 格式读取工作表数据(推荐)
```
Usage:
@@ -77,6 +68,46 @@ Flags:
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
- 单次建议 ≤5000 单元格
### 以 table/dataframe 协议读取结构化数据
```
Usage:
dws sheet table-get [flags]
Example:
dws sheet table-get --node <NODE_ID>
dws sheet table-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D20"
dws sheet table-get --node <NODE_ID> --sheet-id "Sheet1" --no-header
dws sheet table-get --node <NODE_ID> --range "Sheet1!A1:D20"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称;默认不指定
--range string 读取范围,A1 表示法;可带 sheet 前缀,如 Sheet1!A1:D10;默认读取非空范围
--no-header 首行不作为表头,自动生成 col1/col2/...;默认 false
```
**默认行为**:
- 不传 `--sheet-id` / `--range` 时,读取文档内所有工作表
- `--range` 带 sheet 前缀时,以 `range` 中的 sheet 为读取目标
- 不传 `--range` 时,每个目标工作表读取实际非空范围,并裁掉前导全空行/列;显式传 `--range` 时按用户范围原样读取
- 默认首行作为表头;传 `--no-header` 时不消费表头行,并生成 `col1` / `col2` / ...
- 大表或多 sheet 文档建议显式传 `--sheet-id` 和 `--range`,避免一次返回所有工作表造成超时或 token 过大
**返回字段说明**:
- `sheets` — 工作表数组;每项表示一个 table 结果
- `name` / `sheetId` — 工作表名称与 ID
- `range` — 实际读取范围,A1 表示法
- `columns` — 列名数组;默认来自第一行,传 `--no-header` 时生成 `col1` / `col2` / ...
- `data` — 二维数组;默认不含表头行。空单元格 / 缺失值在 table 语义上是 `null`;部分返回链路中也可能表现为空对象 `{}`,此时 `{}` 只表示空位,不是 JSON object 单元格值
- `dtypes` — 按列返回 pandas-style dtype,例如 `object` / `float64` / `bool` / `datetime64[ns]`。普通文本列返回 `object`,当前不返回 `string`
- `formats` — 按列返回 number format code,例如 `@` / `#,##0.00` / `yyyy-mm-dd`
**适用边界**:
- 不传 `--range` 时读取实际非空范围;如需控制数据量,请显式传 `--range` 分批读取
- 首行作为表头时,表头必须非空且不重复;没有稳定表头时传 `--no-header`
- `data[][]` 中的 `{}` 表示空位 / 空单元格,按 `null` 处理;不要把它理解成可写入的对象值
- `table-get` 返回的是值级 table 结构,不返回合并单元格、dataValidation、hyperlink、richText 或 per-cell `cellStyles`。这些信息请使用 `sheet info` 或 `range read`
- `dtypes` / `formats` 是列级推断结果;单列混合类型通常按 `object` 处理。即使 `table-put` 写入时声明 `string`,文本列回读也通常是 `object`
- 大整数/长数字 ID 如果在 `table-get` 中回读为 `float64`,不要把它当作逐位精确值使用;超过 `9007199254740991` 的整数必须在写入时按文本保存,回读应表现为字符串 / `object`,必要时用 `range read --value-render-option raw_value` 复核
### 读取工作表数据(per-cell 结构化信息)
```
Usage:
@@ -107,7 +138,7 @@ Flags:
| `dataValidation` | object | 仅有数据验证时出现,无则省略 | 数据验证配置,见下表 |
| `hyperlink` | object | 仅有单元格级超链接时出现,无则省略 | 整格超链接,结构为 `{type, link, text?}`,见下表 |
| `richText` | object | 仅富文本单元格出现 | 富文本结构(含超链接、附件、图片、样式片段等),普通纯文本不含此字段 |
| `cellStyles` | object | 仅有显式设置的样式时出现;MCP 序列化层也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 |
| `cellStyles` | object | 仅有显式设置的样式时出现;部分返回链路也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 |
`range read` / `range get` 不返回合并单元格结构。要看合并单元格,请先或另行调用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json`,使用其中的 `mergedRanges`。
@@ -174,7 +205,7 @@ Flags:
}
```
说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:MCP 平台序列化会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。
说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:部分返回链路会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。
**取值模式说明**:
| 模式 | value 返回内容 | 适用场景 |
@@ -183,6 +214,8 @@ Flags:
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
**公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
@@ -219,5 +252,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确

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