Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbd42cc25 | ||
|
|
2cc24de505 | ||
|
|
6575301a3a | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
93a2071837 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
5de36d783a | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
ea7c66b190 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
3027337a34 | ||
|
|
d0ad33034e | ||
|
|
5f13876e6e | ||
|
|
116117e987 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
2bd6b297b0 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2681,7 +2681,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 + 原因),**禁止**用安装默认值绕过
|
||||
|
||||
@@ -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;**不**改安装/升级默认。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+599
-373
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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, ¶meter); 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
|
||||
|
||||
@@ -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(¤t, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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__}:")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,仅在不适用时降级到原子命令。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user