Compare commits

..
Author SHA1 Message Date
玉澜andCursor 50d7e7d98f docs: add declarative command framework migration guide
Give engineers a practical Tier1/Tier2/Tier3 checklist for migrating
helpers leaves and shortcuts onto corecmd declare → ContractFinal →
ResolveSchemaBuild, without resurrecting schema pins.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:27:19 +08:00
203 changed files with 4292 additions and 23328 deletions
-1
View File
@@ -817,7 +817,6 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
-65
View File
@@ -6,73 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57-beta.2"
version "1.0.56-beta.4"
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.2/dws-darwin-arm64.tar.gz"
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
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.2/dws-linux-arm64.tar.gz"
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
end
def install
+1 -6
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -33,7 +33,6 @@ help:
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -88,7 +87,6 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(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
@$(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
@@ -128,9 +126,6 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -0,0 +1,368 @@
# 声明式命令框架迁移指南
- **受众**:把 helpers 叶子 / Shortcut 从 Tier3 / 遗留 annotate 迁到声明式框架的工程师
- **规范权威**(本文不重复政策全文,只给可执行路径):
- [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(声明定义、三档路径、Schema 字段权威)
- [`flag-help-schema-homology.md`](flag-help-schema-homology.md)(路径 A:Contract 嵌入 Schema)
- 仓库根 [`AGENTS.md`](../AGENTS.md)(Authoring tiers、Agent Schema contract、curation)
- **架构速览**:[`command-framework-architecture.md`](command-framework-architecture.md)
- **实现基线**:PR #830 起 `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` → `corecmd.New`;Schema 经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 运行时组装(**声明即 Catalog**)
---
## 1. Why / 终态
目标不是「再写一份 Catalog JSON」,而是 **一份声明同时驱动**:
1. Cobra 可执行表面(flags / required / constraints / help)
2. 运行时确认门(`confirmation=user_required` → `ConfirmSafety`)
3. Agent Schema(`ToolSpec` / `ResolveMeta`)
**单向数据流(摘要)**:
```text
leaf Safety + Contract / ParamDecl / ProductDecl
→ ContractFinal(corecmd.New 或 AttachContract / DeclareLeafMetadata)
→ CollectIdentitySpecs(活 Cobra 叶上的 Identity)
→ ResolveSchemaBuild → SchemaRegistry
→ dws schema / ResolveMeta(同一组装结果)
```
硬约束:
| 要做 | 不要做 |
|---|---|
| 在叶子旁声明 `Safety` / `Contract` / `ParamDecl` | 提交 `schema_catalog/`、`schema_meta_index.*`、`schema_agent_metadata/`、`schema_hints/`、`schema_mcp_metadata.json` |
| 用 `make generate-schema` 刷新 param aliases + 证明组装确定性 | 把 Catalog dump 当交付权威或手工改 wire |
| help/Schema 事实 **declare OR annotate** | 纯推断、或钩子闭包里「顺带」发明表面 |
| `Confirmation` 单独驱动运行时门 | 从 `effect`/`risk` 机械推导 `confirmation` |
`cmd_schema_catalog` 仅供 CI/local dump;生产路径没有 committed Catalog pin。
---
## 2. Authoring tiers(何时用哪一档)
同一 `ContractFinal` 语义;三档不是互相否定。详见 RFC §5.0.2a。
| 档 | 入口 | 声明什么 | 执行面 | 何时用 |
|---|---|---|---|---|
| **Tier1** | `corecmd.New` / `helpers.NewLeafCommand(LeafSpec)` | `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` 全进 Spec | 框架注册 flag、投影参数、`ConfirmSafety`、派发 | **新命令默认**;执行面可迁入 LeafSpec/`Call` |
| **Tier2** | `helpers.DeclareLeafMetadata(cmd, LeafSpec)` | 仅 `Safety` + `Contract`(可选 `Validate`) | **不**注册 flag;确认挂 RunE 包装器 | helpers / Shortcut 迁移态:先补 Agent Schema,执行面暂时手写 `RunE`/`Execute` |
| **Tier3** | 裸 `*cobra.Command` | 无框架声明(或仅 annotate / 精确排除) | 调用方自管 | 应收窄;新增裸叶须迁元数据或进 `schema_command_exclusions.go` |
选用规则(今日):
1. 新命令 → **Tier1**。
2. 既有叶子要进 Schema、但 `RunE`/`Execute` 还不想重写 → **Tier2**(声明写在命令字面量旁)。
3. **Shortcut + `DeclareLeafMetadata` 合法**;多数 Shortcut 已走 `FromShortcut` → `corecmd.New`(Tier1 表面 + 自有 `Execute`)。
4. Tier2 → Tier1 与 Shortcut → mcpbind 是后续里程碑,**不是**本阶段硬门槛;不得用 Tier2 绕开「业务 flag 必须声明」的纪律(半接管字段会 panic)。
`DeclareLeafMetadata` 禁止传入:`Flags` / `Constraints` / `ConstParams` / `Call` / `RunE` / `PostMount` / `ConfirmFirst` / `Server`/`Tool`;**唯一允许的执行钩子是 `Validate`**。
---
## 3. What to declare(声明清单)
### 3.1 框架声明面(数据字段才算声明)
| 字段 | 作用 | Schema / 运行时 |
|---|---|---|
| `Flags`(Tier1) | kebab-case flag、Kind、Default、Required、`Bind`、Aliases… | parameters;`Bind` → property |
| `Constraints`(Tier1) | `at_least_one` / `exactly_one` / `mutually_exclusive` / custom | constraints 段 + 运行时校验 |
| `Safety`(`contract.SafetySpec`) | `effect` / `risk` / `confirmation` / `idempotency` **四字段齐全** | ToolSpec Safety;仅 `confirmation` 驱动确认门 |
| `ConstParams`(Tier1) | 固定 toolArgs,不上用户 flag 表 | 载荷;非 parameters |
| `Contract`(`corecmd.ContractDecl`) | Identity / Description / Selection / Parameters / Interface / DryRun | ContractFinal → Catalog |
| `ProductDecl` | 产品级 routing(`contract.RegisterProductDecl`) | 产品 overview selection |
钩子(`Validate` / `Call` / `Invoke` / `Orchestrate` / `RunE` / `Execute` / `PostMount`)**不算**声明——行为正确也不能替代 Schema 事实。
### 3.2 Contract 必填要点
- `Identity`:`ProductID` / `Name` / `CanonicalPath` / `CLIPath` / `PrimaryCLIPath`(与活 Cobra 路径一致;collector 以此为唯一 identity 源)
- `Description`:构造期必填(声明证据);Catalog **交付**可优先 Cobra Long(provenance `cobra_help`)
- `Selection`:决策向 `AgentSummary` / `UseWhen` / `AvoidWhen` / `Examples`(勿复述 Short;**禁止** `Reviewed` 字段)
- `Parameters`(`[]contract.ParamDecl`):CLI flag 名 → RPC `Property`;`interface_type` 等接口事实写这里,不写 MCP pin
- `Interface`:`mcp` / `composite` / `local` + `Ref` 或 `Reason`
### 3.3 ProductDecl
在产品根命令构造处注册一次,例如 `internal/helpers/drive.go` 的 `newDriveCommand`:
```go
contract.RegisterProductDecl(contract.ProductDecl{
ID: "drive",
Selection: contract.ProductSelectionDecl{
AgentSummary: "…",
UseWhen: []string{"…"},
AvoidWhen: []string{"…"},
},
})
```
---
## 4. 分步迁移清单
### 4.1 典型 helpers 叶子(裸 Cobra → Tier2)
1. **确认 CLI 路径**:`dws <path> --help`,记下 flag 名与 required 组。
2. **选定 Identity**:`product_id` / `name` / `canonical_path` / `cli_path` 与兄弟命令不冲突;aliases 若有则写进 Identity(与 collected registry 一致)。
3. **声明 Safety**:写操作填完整四字段;删除/破坏性用 `confirmation=user_required`(见 §5)。
4. **声明 Contract**:`Description` + `Selection` + `Interface`;flag→RPC 用 `Parameters: []ParamDecl{{Name, Property}}`。
5. **挂元数据**:在 flag 注册与 `RunE` 之后调用:
```go
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{ /* 四字段 */ },
Contract: LeafContract{ /* Identity / Selection / … */ },
// 本地副作用且 user_required:补 Validate,避免确认抢先或 fail-closed
})
```
6. **Property 缺口**:CLI 有、RPC 无的 selector → `schema_parameter_mapping_ledger.go` 的 `mapping_exclusions`(精确键 + 非空 reason),不要造 binding JSON。
7. **产品路由**:若产品尚无 `ProductDecl`,在产品根补注册。
8. **验证**(§7)后再开 PR。
参考:`internal/helpers/drive.go`(`DeclareLeafMetadata` + `RegisterProductDecl`)。
### 4.2 典型 Shortcut(补 Safety + Contract)
1. Shortcut 已由 `FromShortcut` → `corecmd.New` 接管 flag/约束/确认;迁移焦点是 **显式 `Safety` + `Contract`**,不要再依赖仅 `Risk` 推断。
2. 在 `shortcut.Shortcut{…}` 字面量上增加:
```go
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{ /* … */ },
Description: "…",
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "…"},
Selection: contract.SelectionSpec{
AgentSummary: "…",
UseWhen: []string{"…"},
AvoidWhen: []string{"…"},
Examples: []string{"dws chat +conversation-info --group <openConversationId>"},
},
},
```
3. `Flags` / `Constraints` / `Validate` / `Execute` 保持 Shortcut 词汇;adapter 映射到 `corecmd.Spec`。
4. Examples 用真实可执行路径与 flag;**不要**写 `--yes`。
5. 验证(§7)。
参考:`internal/shortcut/chat/chat_conversation.go`(`ConversationInfo`);adapter:`internal/shortcut/adapter.go`。
### 4.3 新命令 / 可重写执行面 → Tier1
优先 `NewLeafCommand(LeafSpec{ Flags, Safety, Contract, Call/… })`,例如 `internal/helpers/devapp.go`。业务 flag 只进 `Flags`,禁止在 `PostMount`/`Validate` 里 `Flags().String` 注册业务面。
---
## 5. Safety / confirmation
### 5.1 声明完整 SafetySpec
```go
Safety: contract.SafetySpec{
Effect: "destructive", // read | write | destructive
Risk: "high", // low | medium | high
Confirmation: "user_required", // not_required | user_required
Idempotency: "unknown", // idempotent | retryable | non_idempotent | unknown
},
```
非空则四字段必须齐全(构造期校验)。字段独立:`effect=destructive` **不会**自动变成 `user_required`。
### 5.2 迁离 AnnotateRuntimeRisk / AnnotateRuntimeGate
| 旧路径 | 新路径 |
|---|---|
| `AnnotateRuntimeRisk` / 字符串 `dws.schema.risk` | 声明 `Safety.Risk`(经 ContractFinal) |
| `AnnotateRuntimeGate` / `runtime_gate`(如 `devAppRequireWriteGuard`) | 声明 `Safety.Confirmation=user_required`;框架 `ConfirmSafety` |
| 仅 Shortcut `Risk=write` 隐式确认 | 显式 `Safety`(覆盖 Risk 展开) |
**禁止新增**生产 `AnnotateRuntimeRisk` / `AnnotateRuntimeGate` 调用点;存量 annotate 可保留至迁完(`HOM-S2`)。同源门禁:`confirmation=user_required` ↔ 运行时 gate(见 `check-runtime-confirmation-truth.sh`、`internal/cli/homology`)。
### 5.3 Tier2 确认时机
- 有 `Validate`:`Validate` → `ConfirmSafety` → 原 `RunE`
- 无 `Validate`:确认推迟到首次 `CallTool`;无 Caller 的本地副作用叶必须补 `Validate`,否则 fail-closed
执行前需确认时,用 `--yes` 跳过交互;**Schema examples 永不包含 `--yes`**。
---
## 6. Parameters / mapping
1. **主权威**:Tier1 用 `FlagSpec.Bind`(空则 Name);Tier2 / 混合路径用 `contract.ParamDecl{Name, Property}`。
2. **mapping ledger**(`internal/cli/schema_parameter_mapping_ledger.go`):只放 `mapping_exclusions` / removals——CLI flag **无**直接 RPC property 时的精确评审排除;非空 reason。
3. **不要**:提交 MCP pin(`schema_mcp_metadata.json` 已退役);不要指望 live MCP 创建 CLI flag;不要用 hints overlay 改 `type`/`required`/`default`。
4. **required 地板**:Cobra `MarkFlagRequired` 不得被低优先级源降为 optional。
5. 接口事实(`interface_ref` / `interface_type`)声明在 leaf `Contract.Interface` / `ParamDecl`;CLI path ≠ MCP path 时用 `Interface.Ref`(例:`drive delete` → `doc.delete_document`)。
---
## 7. Schema 验证
迁移后至少跑:
```bash
make generate-schema
./scripts/policy/check-schema-catalog.sh
./scripts/policy/check-runtime-confirmation-truth.sh # 若动了 confirmation / gate
# 聚焦:
go test ./internal/app -run 'TestSheetFinalSchemaConfirmationMatchesRuntimeGuards|TestFinalSchemaParametersMatchExecutableHelpFlags' -count=1
```
手工抽查:
```bash
dws schema --cli-path "drive mkdir" -f json
# 或 ResolveMeta 同源:help Safety 行与 Schema confirmation 一致
dws drive mkdir --help
```
Examples 规则(组装/门禁):
- 每 tool 最多两条;路径与 flag 必须是活 Cobra 可接受的
- **禁止** `--yes`;禁止 shell 注释
- 缺必填 / 约束失败 = 契约 bug,不是「跳过 example」的理由
可选:`make test-schema-agent-examples`(合同 + 显式 dry-run 能力子集)。
---
## 8. Pitfalls(常见坑)
1. **Schema source root = declarationOnly**
`app.NewSchemaSourceRootCommand` 以 `declarationOnly=true` 建树:**跳过** `helpers.InitDeps` / `injectStaticServers`,避免组装时清掉进程里的 ToolCaller / plugin endpoint。叶子构造与 RunE 包装必须对 `deps == nil` 安全(见 `printDocDeprecationWarning` 注释,`internal/helpers/doc.go`)。
2. **DryRun `RemoteReads`**
声明 `Contract.DryRun` / `DryRunSpec` 时,`RemoteReads: false` 表示预览计划不发起远程读;勿把「有 dry-run 能力」与「会打后端」混为一谈。无 reviewed dry-run 能力时,example 门禁不会魔法升级为 runtime dry-run。
3. **不要复活退役目录 / pin**
`schema_hints/`、`schema_agent_metadata/`、`schema_command_registry/`、committed `schema_catalog/`、`schema_mcp_metadata.json` —— 出现即 policy 失败。
4. **Tier2 半接管**
往 `DeclareLeafMetadata` 塞 `Flags`/`Call`/`RunE` 会 panic;要框架管 flag → 升 Tier1。
5. **Identity 漂移**
`Contract.Identity` 必须与活路径一致;不一致组装失败。排除项用 `schema_command_exclusions.go` 精确路径 + reason,禁止前缀通配。
6. **Selection 里塞 `Reviewed`**
旧 hints 标记;声明载荷携带即组装报错。
7. **在钩子里发明 flag/property**
Schema 看不到;同源门禁(`HOM-P*` / `HOM-D1`)会打回来。
---
## 9. Worked mini-example(前后对照)
### 9.1 helpers:裸叶 → Tier2(模式摘自 `drive mkdir` / `drive delete`)
**Before(概念)**:手写 `cobra.Command` + `RunE` + `Flags().String`,无 `Safety`/`Contract` → Agent Schema 缺叶子或靠已退役 hints。
**After(Tier2,缩写)**:
```go
driveMkdirCmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
RunE: /* 既有 callMCPTool("create_folder", …) */,
}
driveMkdirCmd.Flags().String("name", "", "…")
driveMkdirCmd.Flags().String("folder", "", "…")
DeclareLeafMetadata(driveMkdirCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive", Name: "create_folder",
CanonicalPath: "drive.create_folder",
CLIPath: "drive mkdir", PrimaryCLIPath: "drive mkdir",
},
Description: "创建文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp", Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "create_folder"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建文件夹",
UseWhen: []string{"用户要在钉盘下新建普通文件夹时"},
AvoidWhen: []string{"知识库内建文件夹改用 dws wiki node create …"},
Examples: []string{`dws drive mkdir --name "项目资料" --format json`},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "parentId"},
},
},
})
```
破坏性写示例(`drive delete`):`Confirmation: "user_required"`,`Effect: "destructive"`,`Interface.Ref` 可指向 `doc`/`delete_document`。
路径:`internal/helpers/drive.go`。
### 9.2 Tier1 LeafSpec(`dev app event list` 缩写)
```go
return NewLeafCommand(LeafSpec{
Use: "list", Short: "查询应用已订阅的事件列表",
Tool: devAppEventListTool,
Safety: /* read / low / not_required / idempotent */,
Flags: []LeafFlag{
{Name: "unified-app-id", Bind: "unifiedAppId", Required: true, Trim: true},
{Name: "keyword", Bind: "keyword", OmitEmpty: true, Trim: true},
},
Contract: LeafContract{ /* Identity + Selection + Interface */ },
Call: devAppCallCursor(runner),
})
```
路径:`internal/helpers/devapp.go`。
### 9.3 Shortcut(`chat +conversation-info`)
见 §4.2;完整字面量:`internal/shortcut/chat/chat_conversation.go`。`FromShortcut` 把 `Flags`/`Constraints`/`Safety`/`Contract` 编进 `corecmd.Spec`(`internal/shortcut/adapter.go`)。
---
## 10. Done criteria(完成标准)
迁移 PR 合并前,下列应满足:
| 检查项 | 证据 |
|---|---|
| 叶子有完整 `Safety`(或存量 `runtime_gate` 未扩大) | `dws schema --cli-path "…" -f json` 四字段与声明一致 |
| `user_required` ↔ 运行时确认 | `./scripts/policy/check-runtime-confirmation-truth.sh`;相关 `HOM-S1`/`HOM-S2` |
| Identity / 路径可执行且进 Schema(或精确 exclusion) | `CollectIdentitySpecs` / reverse-completeness;无前缀排除 |
| parameters ⊆ help flags;property 有声明或 mapping exclusion | `check-schema-catalog.sh`;`HOM-P1`/`HOM-D1` |
| 无退役 pin / hints / agent_metadata / committed catalog | policy 脚本;`git status` 无这些路径 |
| `make generate-schema` 通过(aliases + 组装确定性) | CI / 本地 |
| Examples 可执行、无 `--yes` | 组装 example 门禁;必要时 `make test-schema-agent-examples` |
| gofmt;未无关改动 | PR diff |
长期可选(非本阶段硬门槛):Tier2 → Tier1;Shortcut `Execute` 中「只为装配参数」的函数体收敛到 mcpbind 形态 1/2(RFC §3.5 / §5.0.2a.5)。
---
## 附录:相关文件速查
| 主题 | 路径 |
|---|---|
| Leaf Tier1/Tier2 API | `internal/helpers/leaf.go` |
| Leaf → corecmd | `internal/helpers` `FromLeafSpec`;`internal/corecmd` |
| Shortcut → corecmd | `internal/shortcut/adapter.go` |
| Safety / Contract DTO | `internal/corecmd/contract` |
| ContractFinal 注册 | `internal/corecmd/contractfinal` |
| Schema 组装入口 | `internal/cli`:`RegisterSchemaSourceRoot`、`ResolveSchemaBuild`、`ResolveMeta` |
| 精确排除 | `internal/cli/schema_command_exclusions.go` |
| mapping exclusions | `internal/cli/schema_parameter_mapping_ledger.go` |
| declaration-only 根 | `internal/app.NewSchemaSourceRootCommand` |
+45 -386
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"generated_at": "2026-07-29T00:06:19.285348",
"count": 265,
"results": [
{
"suite": "read",
@@ -485,7 +485,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"availability": "available"
},
{
@@ -658,16 +658,6 @@
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
"command": "+chat-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
@@ -725,7 +715,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"availability": "available"
},
{
@@ -1225,7 +1215,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"availability": "available"
},
{
@@ -1305,7 +1295,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"availability": "available"
},
{
@@ -1325,7 +1315,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"availability": "available"
},
{
@@ -1354,8 +1344,8 @@
"command": "+messages-send-card",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"availability": "available"
},
{
@@ -1425,7 +1415,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"availability": "available"
},
{
@@ -1445,7 +1435,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"availability": "available"
},
{
@@ -1718,454 +1708,123 @@
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-change",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-grant",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-revoke",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-delete",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以 clear 语义移除文档背景色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+checkpoint-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+comment-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+comment-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+comment-reply",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "更新指定评论正文与 mention。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create-from-template",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+doc-append",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+export",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+export-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+export-submit",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+fetch",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+find-doc",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+grant-and-share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+import",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-insert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-preview",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+move",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+review",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+share-doc",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+template-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "write",
-1
View File
@@ -99,7 +99,6 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
-295
View File
@@ -1,295 +0,0 @@
// 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 app
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"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/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
-360
View File
@@ -1,360 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
want := []string{"consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
}
}
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
@@ -29,7 +29,6 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
@@ -38,7 +37,6 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -121,7 +119,6 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -132,7 +129,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -173,7 +169,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -224,7 +220,6 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -257,7 +252,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -297,7 +292,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -308,23 +302,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
-33
View File
@@ -72,39 +72,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
+14 -41
View File
@@ -413,33 +413,6 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -705,6 +678,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -721,14 +695,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := newServerFailureAPIError(
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -746,15 +720,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
+17 -25
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 294
publicShortcutCount = 266
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 295
schemaPublishedShortcutCount = 216
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 294
publiclyDeliveredShortcutCount = 215
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 180; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -194,9 +194,17 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
@@ -226,15 +234,6 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -300,13 +299,6 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
-105
View File
@@ -1,105 +0,0 @@
// 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 app
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -1,223 +0,0 @@
// 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 app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
+12 -3
View File
@@ -23,11 +23,11 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -117,14 +117,23 @@ type Profile struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
profilectx.Set(profile)
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
return profilectx.Get()
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
-12
View File
@@ -158,12 +158,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-messages",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +chat-mute",
Aliases: map[string]string{
@@ -342,12 +336,6 @@ var generatedParamAliases = []ParamAliasEntry{
"at-user-ids": "at-users",
},
},
{
CLIPath: "chat +search-msg",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +send-to-group",
Aliases: map[string]string{
+1 -5
View File
@@ -57,8 +57,6 @@
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
@@ -257,9 +255,7 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
]
}
}
-6
View File
@@ -1142,12 +1142,6 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
}
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
// JSON Schema's date-time format means one RFC3339 value. Do not publish
// that narrower wire contract when the CLI also accepts local timestamps
// or date-only values alongside RFC3339.
if strings.Contains(usage, "yyyy-mm-dd") {
return ""
}
return "date-time"
}
if strings.Contains(usage, "a1") {
+18 -9
View File
@@ -30,29 +30,38 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode = contract.ExampleDispositionMode
type AgentExampleMode string
const (
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
type AgentExampleReasonCode string
const (
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
-1
View File
@@ -178,7 +178,6 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
+58
View File
@@ -171,4 +171,62 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"todo task remove-attachment",
},
},
{
ID: "chat-shortcuts-pending-schema-curation",
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
Reviewed: true,
Commands: []string{
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-list",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send-by-bot",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
},
},
}
@@ -123,11 +123,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
}}
return selection
}
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -141,12 +146,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
}}
})
return selection
}
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
-4
View File
@@ -332,10 +332,6 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
-48
View File
@@ -200,10 +200,6 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -223,54 +219,10 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,14 +75,9 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -91,16 +86,6 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+10 -15
View File
@@ -103,12 +103,11 @@ const ValidationShortcut FlagValidationMode = "shortcut"
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
// helpers can alias to it without touching any call site.
type FlagSpec struct {
Name string // flag name (kebab-case)
Shorthand string // optional one-character Cobra shorthand
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
Name string // flag name (kebab-case)
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
// Required, when true, validates a non-empty effective value in RunE. Plain
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
@@ -552,7 +551,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
flag.Name))
}
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
// Aliases are registered with the main flag's Kind, otherwise an integer
// alias's value would never be readable (silently dropped).
for _, alias := range flag.Aliases {
@@ -573,10 +572,6 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
// instead of silently degrading to 0 / false.
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
registerFlagP(cmd, kind, name, "", def, usage)
}
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
switch kind {
case KindInt:
defInt := 0
@@ -587,7 +582,7 @@ func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usag
}
defInt = v
}
cmd.Flags().IntP(name, shorthand, defInt, usage)
cmd.Flags().Int(name, defInt, usage)
case KindBool:
defBool := false
if def != "" {
@@ -600,15 +595,15 @@ func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usag
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
}
}
cmd.Flags().BoolP(name, shorthand, defBool, usage)
cmd.Flags().Bool(name, defBool, usage)
case KindStringSlice:
var defaults []string
if value := strings.TrimSpace(def); value != "" {
defaults = strings.Split(value, ",")
}
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
cmd.Flags().StringSlice(name, defaults, usage)
default:
cmd.Flags().StringP(name, shorthand, def, usage)
cmd.Flags().String(name, def, usage)
}
}
+4 -9
View File
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
cmd := newTestCommand()
RegisterFlags(cmd, []FlagSpec{
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "s", Usage: "S", Default: "d"},
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "req", Usage: "R", MarkRequired: true},
{Name: "hidden", Usage: "H", Hidden: true},
})
@@ -78,11 +78,6 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
t.Fatalf("string flag = %#v", f)
}
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
}
}
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
f := cmd.Flags().Lookup(name)
if f == nil || f.Value.Type() != wantType {
-66
View File
@@ -46,9 +46,6 @@ type Error struct {
Message string
Operation string
ServerKey string
Origin string
FailureStage string
ExecutionStarted *bool
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
@@ -58,7 +55,6 @@ type Error struct {
Actions []string
AvailableFlags []string
Snapshot string
Details map[string]any
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
@@ -111,32 +107,6 @@ func WithServerKey(serverKey string) Option {
}
}
// WithOrigin records the component that produced the failure, such as the
// client, MCP gateway, or DingTalk API. It is independent from Category,
// which remains the stable exit-code contract.
func WithOrigin(origin string) Option {
return func(err *Error) {
err.Origin = strings.TrimSpace(origin)
}
}
// WithFailureStage records the execution stage at which the failure occurred.
func WithFailureStage(stage string) Option {
return func(err *Error) {
err.FailureStage = strings.TrimSpace(stage)
}
}
// WithExecutionStarted records whether the downstream business operation was
// known to have started. Unknown state must be represented by omitting this
// option, which is important for safe retry decisions on write operations.
func WithExecutionStarted(started bool) Option {
return func(err *Error) {
value := started
err.ExecutionStarted = &value
}
}
// WithRetryable marks whether the error can be retried safely.
func WithRetryable(retryable bool) Option {
return func(err *Error) {
@@ -216,21 +186,6 @@ func WithSnapshot(path string) Option {
}
}
// WithDetails records an additive machine-readable payload for errors whose
// recovery needs typed context, such as ambiguous target-resolution
// candidates. Callers must keep credentials and other secrets out of details.
func WithDetails(details map[string]any) Option {
return func(err *Error) {
if len(details) == 0 {
return
}
err.Details = make(map[string]any, len(details))
for key, value := range details {
err.Details[key] = value
}
}
}
// WithRPCCode records the original JSON-RPC error code.
func WithRPCCode(code int) Option {
return func(err *Error) {
@@ -340,15 +295,6 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Origin != "" {
errorPayload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
errorPayload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
errorPayload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
@@ -370,9 +316,6 @@ func PrintJSON(w io.Writer, err error) error {
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
if len(typed.Details) > 0 {
errorPayload["details"] = typed.Details
}
if typed.RPCCode != 0 {
errorPayload["rpc_code"] = typed.RPCCode
}
@@ -504,15 +447,6 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if typed.ServerKey != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
}
if typed.Origin != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
}
if typed.FailureStage != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
}
if typed.ExecutionStarted != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
}
if typed.Snapshot != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
}
+10 -38
View File
@@ -20,7 +20,7 @@ import (
"time"
)
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
func TestExitCodeByCategory(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -42,24 +42,17 @@ func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
func TestPrintJSON(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("dws schema doc.create_document", "retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
WithDetails(map[string]any{
"type": "resolution",
"query": "项目群",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
@@ -74,11 +67,6 @@ func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
t.Fatalf("expected reason in output, got %q", got)
}
if !strings.Contains(got, "\"origin\": \"client\"") ||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
!strings.Contains(got, "\"execution_started\": false") {
t.Fatalf("expected failure provenance in output, got %q", got)
}
if !strings.Contains(got, "\"retryable\": true") {
t.Fatalf("expected retryable in output, got %q", got)
}
@@ -88,9 +76,6 @@ func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
t.Fatalf("expected snapshot path in output, got %q", got)
}
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
t.Fatalf("expected structured details in output, got %q", got)
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
@@ -186,7 +171,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
}
}
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -207,7 +192,7 @@ func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
func TestPrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -216,9 +201,6 @@ func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
WithServerKey("calendar"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("retry command"),
@@ -246,19 +228,9 @@ func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in verbose output, got %q", want, got)
}
}
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
if withoutDetails.Details != nil {
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
}
}
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
func TestPrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -282,7 +254,7 @@ func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
func TestPrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -317,7 +289,7 @@ func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -341,7 +313,7 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -363,7 +335,7 @@ func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -384,7 +356,7 @@ func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
-6
View File
@@ -3,7 +3,6 @@ package helpers
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -79,8 +78,6 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
_ = cmd.Flags().MarkHidden("type")
}
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
@@ -93,9 +90,6 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
@@ -28,13 +28,3 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
}
}
}
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
t.Fatal(err)
}
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
t.Fatal("invalid person type selector unexpectedly succeeded")
}
}
+74 -213
View File
@@ -18,7 +18,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -54,43 +53,6 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
command := hintSubCmd(use, hint)
// Legacy callers may still pass the old command's flags. Let the migration
// command consume them so Cobra reaches RunE and returns the replacement path.
command.DisableFlagParsing = true
return command
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func resolveNativeChatTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if targetresolver.LooksLikeOpenConversationID(raw) {
return raw, nil
}
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
@@ -116,61 +78,6 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
return v
}
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
if len(args) == 1 {
if keyword != "" {
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
}
keyword = strings.TrimSpace(args[0])
}
if keyword == "" {
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
}
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
cmd := &cobra.Command{
Use: "search [query]",
Short: "根据关键词搜索群聊",
Hidden: hidden,
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
Args: cobra.MaximumNArgs(1),
RunE: runChatGroupSearch,
}
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().String("name", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("name")
cmd.Flags().String("group", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("group")
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = cmd.Flags().MarkHidden("size")
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
return cmd
}
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
return err
@@ -404,50 +311,6 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
return text
}
// applyCurrentUserGroupMentions keeps the body placeholders and
// send_personal_message mention arguments aligned for send and reply.
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
var atOpenIDs []string
if rawOpenIDs != "" {
atOpenIDs = strings.Split(rawOpenIDs, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
text = normalizeAtPlaceholders(text, atOpenIDs, true)
if atAll {
params["atAll"] = true
}
if len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
}
return text
}
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
if rawOpenIDs == "" {
return text
}
missing := make([]string, 0)
probeText := text
for _, id := range parseCSVValues(rawOpenIDs) {
placeholder := "<@" + id + ">"
if strings.Contains(probeText, placeholder) {
continue
}
missing = append(missing, placeholder)
probeText += placeholder
}
if len(missing) == 0 {
return text
}
prefix := strings.Join(missing, " ")
if strings.HasPrefix(text, "<@all> ") {
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
}
return prefix + " " + text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
@@ -1414,23 +1277,34 @@ func newChatCommand() *cobra.Command {
},
})
chatSearchCmd := newChatGroupSearchCommand(false)
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
cli.AttachRuntimeSchema(
chatGroupSearchCompatibilityCmd,
"chat",
"search_groups",
"reviewed-compatibility:chat-group-search",
)
cli.AnnotateRuntimeCompatibilityEquivalence(
chatSearchCmd,
chatGroupSearchCompatibilityCmd,
cli.RuntimeCompatibilityEquivalence{
ID: "chat-group-search-compatibility-v1",
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
Reviewed: true,
chatSearchCmd := &cobra.Command{
Use: "search",
Short: "根据关键词搜索群聊",
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
},
)
}
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -2081,15 +1955,29 @@ func newChatCommand() *cobra.Command {
if groupID != "" {
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
var atOpenIds []string
if atOpenIdsStr != "" {
atOpenIds = strings.Split(atOpenIdsStr, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams := map[string]any{
"openConversationId": groupID,
"msgType": "markdown",
"content": string(contentJSON),
"clawType": clawType,
}
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams["content"] = string(contentJSON)
if atAll {
newParams["atAll"] = true
}
if len(atOpenIds) > 0 {
newParams["atOpenDingTalkIds"] = atOpenIds
}
if msgUuid != "" {
newParams["uuid"] = msgUuid
}
@@ -3465,6 +3353,15 @@ func newChatCommand() *cobra.Command {
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatSearchCmd.Flags().MarkHidden("keyword")
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatSearchCmd.Flags().MarkHidden("size")
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
_ = chatGroupMembersCmd.MarkFlagRequired("id")
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
@@ -3490,7 +3387,7 @@ func newChatCommand() *cobra.Command {
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
// message 子命令 flags
@@ -4043,7 +3940,6 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -4051,14 +3947,6 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -5406,14 +5294,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageReplyCmd := &cobra.Command{
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
@@ -5426,6 +5313,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
refSender = resolved
}
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": mustGetFlag(cmd, "text"),
}
contentJSON, _ := marshalJSONRaw(replyContent)
clawType := ""
aiTag, _ := cmd.Flags().GetBool("ai-tag")
if aiTag {
@@ -5434,25 +5328,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"msgType": "reply",
"content": string(contentJSON),
"clawType": clawType,
}
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "text"),
atOpenIDs,
atAll,
)
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": replyText,
}
contentJSON, _ := marshalJSONRaw(replyContent)
toolArgs["content"] = string(contentJSON)
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
@@ -5486,8 +5364,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "conversation-id", Property: "openConversationId"},
},
},
@@ -5502,8 +5378,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageReplyCmd.MarkFlagRequired("text")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
// ── message forward: 转发单条消息 ────────────────────────
@@ -6387,12 +6261,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
if err != nil {
return err
}
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
"openConversationId": groupID,
"openConversationId": mustGetFlag(cmd, "group"),
})
},
}
@@ -6426,7 +6296,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
},
})
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupBotsCmd.MarkFlagRequired("group")
chatGroupMembersRemoveBotCmd := &cobra.Command{
@@ -6562,7 +6432,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Use: "dismiss",
Short: "解散群聊",
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
Example: ` dws chat group dismiss --group <openConversationId>
Example: ` dws chat group dismiss --group <openConversationId> --yes
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -7406,7 +7276,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -7418,14 +7288,6 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -7901,8 +7763,8 @@ status 可选值:
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -8218,11 +8080,10 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
// compatibility hints available through both chat and im.
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
// hint: dws chat send → dws chat message send
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
// hint: dws chat history → dws chat message list
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
@@ -43,75 +43,6 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
return command.RunE(command, nil)
}
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
t.Fatalf("stable native chat target = %q, %v", got, err)
}
t.Run("group search path accepts query", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("group search accepts positional", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("native bots resolves group name", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
{text: `{"result":{"bots":[]}}`},
}}
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d", caller.calls)
}
})
}
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
root := newChatCommand()
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
}
for _, tc := range []struct {
path string
args []string
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if len(remaining) != 0 || command.Name() != tc.path {
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
}
if !command.Hidden || !command.Runnable() {
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
}
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -181,7 +112,6 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
{"category", "delete", "--category-id=1"},
{"category", "rename", "--category-id=1", "--title=renamed"},
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
@@ -15,7 +15,6 @@ package helpers
import (
"context"
"encoding/json"
"io"
"os"
"reflect"
@@ -286,150 +285,6 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
tests := []struct {
name string
args []string
contentField string
wantContent string
wantAtAll bool
wantOpenIDs []string
}{
{
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
},
{
name: "reply",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "reply adds missing member placeholders",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
},
{
name: "reply adds missing member placeholders after at-all",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
},
{
name: "reply at-all preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 联系 @alliance",
wantAtAll: true,
},
{
name: "reply without at flags preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
},
contentField: "content",
wantContent: "联系 @alliance",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
gotAtAll, hasAtAll := args["atAll"]
if tc.wantAtAll {
if !hasAtAll || gotAtAll != true {
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
}
} else if hasAtAll {
t.Fatalf("atAll = %#v; want absent", gotAtAll)
}
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
if len(tc.wantOpenIDs) > 0 {
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
}
} else if hasOpenIDs {
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
}
var content map[string]string
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if got := content[tc.contentField]; got != tc.wantContent {
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+6 -80
View File
@@ -310,44 +310,6 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -429,7 +391,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact user invite/update/update-self: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -452,7 +414,6 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1394,40 +1355,6 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1445,12 +1372,11 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,7 +48,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -99,18 +98,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -152,7 +139,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -188,10 +174,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
+3 -53
View File
@@ -816,8 +816,6 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2527,54 +2525,6 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2637,7 +2587,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2651,7 +2601,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -4277,7 +4227,7 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
return root
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
-24
View File
@@ -1,24 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "github.com/spf13/cobra"
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
// to the Shortcut application layer. The Cobra leaf still owns its own flags
// and Contract; this bridge only shares the raw/API execution primitive.
func RunDocImportShortcut(cmd *cobra.Command) error {
return runImportCommand(cmd, nil, docImportFlowConfig())
}
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
// insertion implementation with the canonical Doc Shortcut.
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
return runMediaInsert(cmd, nil)
}
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
return runDocStyleCoverSet(cmd, nil)
}
-284
View File
@@ -1,284 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
whiteboardDefaultHeight = 600
)
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
// 继续调用 whiteboard query/update。
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
var (
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
whiteboardSleep = time.Sleep
whiteboardJSONMarshal = json.Marshal
prepareWhiteboardCard = prepareJsonMLNode
)
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
node := []any{
"card",
map[string]any{
"uuid": blockUUID,
"cardType": "hetu",
"height": whiteboardDefaultHeight,
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
},
[]any{"span", map[string]any{"data-type": "text"},
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
}
out, err := whiteboardJSONMarshal(node)
if err != nil {
return ""
}
return string(out)
}
func extractWhiteboardID(attrs map[string]any) string {
meta, _ := attrs["metadata"].(map[string]any)
if meta == nil {
return ""
}
id, _ := meta["id"].(string)
return id
}
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID,
"blockId": blockID,
"format": "jsonml",
})
if err != nil {
return nil, err
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
blocksField, ok := data["blocks"]
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
}
blocks, ok := blocksField.([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
}
var raw string
for _, block := range blocks {
entry, _ := block.(map[string]any)
if entry == nil || entry["blockId"] != blockID {
continue
}
raw, _ = entry["jsonml"].(string)
break
}
if raw == "" {
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
}
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
return nil, fmt.Errorf("parse block jsonml: %w", err)
}
return node, nil
}
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
if err != nil {
return nil, err
}
if len(node) < 2 {
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
}
attrs, _ := node[1].(map[string]any)
if attrs == nil {
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
}
return attrs, nil
}
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
blockUUID := uuid.New().String()
whiteboardID := uuid.New().String()
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
normalized, err := prepareWhiteboardCard(cmd, element)
if err != nil {
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
}
toolArgs := map[string]any{
"nodeId": nodeID,
"jsonml": normalized,
"format": "jsonml",
}
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
toolArgs["referenceBlockId"] = v
where, _ := cmd.Flags().GetString("where")
if where == "" {
where = "after"
}
toolArgs["where"] = where
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
toolArgs["referenceBlockId"] = v
}
if cmd.Flags().Changed("index") {
index, _ := cmd.Flags().GetInt("index")
toolArgs["index"] = index
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
ctx := cmd.Context()
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
return err
}
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
persistedID := ""
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
switch {
case queryErr == nil:
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
persistedID = extractWhiteboardID(attrs)
case errors.Is(queryErr, errWhiteboardBlockPending):
// 块暂不可见,属于最终一致性,继续重试。
default:
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
return fmt.Errorf(
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
blockUUID, queryErr)
}
if persistedID != "" {
break
}
if attempt < len(whiteboardRetryDelays) {
whiteboardSleep(whiteboardRetryDelays[attempt])
}
}
result := map[string]any{"blockId": blockUUID}
if persistedID == "" {
result["whiteboardId"] = nil
deps.Out.PrintWarning(fmt.Sprintf(
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
nodeID, blockUUID))
} else {
result["whiteboardId"] = persistedID
}
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
insertCmd := &cobra.Command{
Use: "insert",
Short: "插入白板卡片",
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
不能同时使用。`,
Example: ` dws doc whiteboard insert --node DOC_ID
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
RunE: runWhiteboardInsert,
}
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
insertCmd.Flags().String(name, "", "--node 的兼容别名")
_ = insertCmd.Flags().MarkHidden(name)
}
DeclareLeafMetadata(insertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "whiteboard_insert",
CanonicalPath: "doc.whiteboard_insert",
CLIPath: "doc whiteboard insert",
PrimaryCLIPath: "doc whiteboard insert",
},
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
},
},
})
root.AddCommand(insertCmd)
return root
}
+1 -3
View File
@@ -66,8 +66,6 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -631,7 +629,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if driveWorkerContextErr(runCtx) != nil {
if runCtx.Err() != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+40 -26
View File
@@ -13,8 +13,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2870,32 +2868,48 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
var checks atomic.Int32
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
checks.Add(1)
return context.Canceled
})
var requests atomic.Int32
testseam.Swap(t, &driveRangeClient, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
t.Fatalf("downloadRangedParts context guard: %v", err)
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// 每次请求加小延迟,让总处理时间接近 deadline
time.Sleep(50 * time.Microsecond)
var start, end int64
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
}
if end >= int64(len(content)) {
end = int64(len(content)) - 1
}
resp := &http.Response{
StatusCode: http.StatusPartialContent,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
}
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
return resp, nil
}),
}
if checks.Load() != 1 {
t.Fatalf("worker context checks = %d, want 1", checks.Load())
}
if requests.Load() != 0 {
t.Fatalf("worker requests = %d, want 0", requests.Load())
// 多次尝试以确保覆盖(goroutine 调度非确定性)
for attempt := 0; attempt < 50; attempt++ {
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
cancel()
}
}
-1
View File
@@ -64,7 +64,6 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"enterprise_person_search": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
-3
View File
@@ -190,9 +190,6 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
if toolName == "enterprise_person_search" {
return true
}
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
-11
View File
@@ -1,11 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
})
}
@@ -271,10 +271,6 @@ func TestCrossPlatformCoverageProtectSheetMutationCommandPanics(t *testing.T) {
func TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety(t *testing.T) {
// Sheet agent hardening: outer --yes-only gate must win over ConfirmSafety
// honoring piped stdin yes (review: delete-sheet / range clear / version revert).
// This fixture exercises the no-caller fallback. Isolate it from commands
// built by earlier tests, which may initialize the package-level deps.
testseam.Protect(t, &deps)
deps = nil
ran := false
cmd := &cobra.Command{
Use: "delete-sheet",
-352
View File
@@ -1,352 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardServerID = "whiteboard"
whiteboardQueryTool = "read_whiteboard_content"
whiteboardUpdateTool = "update_whiteboard"
)
type whiteboardUpdateFile struct {
Overwrite bool `json:"overwrite"`
Source *whiteboardOpenSource `json:"source"`
}
type whiteboardOpenSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
var compactWhiteboardJSON = json.Compact
func newWhiteboardCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "whiteboard",
Selection: contract.ProductSelectionDecl{
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
queryCmd := &cobra.Command{
Use: "query",
Short: "读取白板内容",
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
return err
}
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
})
},
}
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
DeclareLeafMetadata(queryCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "query",
CanonicalPath: "whiteboard.query",
CLIPath: "whiteboard query",
PrimaryCLIPath: "whiteboard query",
},
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
},
},
})
updateCmd := &cobra.Command{
Use: "update",
Short: "追加或整页重建白板内容",
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
return err
}
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
if err != nil {
return err
}
mode := "append"
if input.Overwrite {
mode = "overwrite"
}
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
"mode": mode,
"nodes": nodesJSON,
})
},
}
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
updateExampleIndex := 0
DeclareLeafMetadata(updateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "update",
CanonicalPath: "whiteboard.update",
CLIPath: "whiteboard update",
PrimaryCLIPath: "whiteboard update",
},
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
ExampleDispositions: []contract.ExampleDisposition{{
Index: &updateExampleIndex,
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonLocalState,
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
Reviewed: true,
}},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
{Name: "source", Required: boolPtr(true)},
},
},
})
root.AddCommand(queryCmd, updateCmd)
return root
}
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
for _, name := range []string{"jq", "fields"} {
flag := cmd.Flags().Lookup(name)
if flag == nil {
flag = cmd.InheritedFlags().Lookup(name)
}
if flag != nil && flag.Changed {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
Suggestion: "直接读取命令返回的结构化 JSON",
}
}
}
return nil
}
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
data, err := os.ReadFile(path)
if err != nil {
code := CodeInvalidPath
if os.IsNotExist(err) {
code = CodeFileNotFound
}
return nil, "", &CLIError{
Code: code,
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
Cause: err,
}
}
var input whiteboardUpdateFile
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if input.Source == nil {
return nil, "", invalidWhiteboardSourceParam("source is required")
}
if input.Source.SchemaVersion != "1.0" {
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
}
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
if err != nil {
return nil, "", err
}
if !input.Overwrite && nodeCount == 0 {
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
}
return &input, nodesJSON, nil
}
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errors.Is(err, io.EOF) {
return err
}
return nil
}
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
}
var nodes []json.RawMessage
if err := json.Unmarshal(raw, &nodes); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
for i, node := range nodes {
var object map[string]any
if err := json.Unmarshal(node, &object); err != nil || object == nil {
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
}
}
var compact bytes.Buffer
if err := compactWhiteboardJSON(&compact, raw); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
return compact.String(), len(nodes), nil
}
func invalidWhiteboardSourceJSON(err error) error {
return &CLIError{
Code: CodeInvalidJSON,
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
Cause: err,
}
}
func invalidWhiteboardSourceParam(message string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: message,
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
}
}
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer(whiteboardServerID, toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
if err != nil {
return err
}
if text == "" {
return nil
}
var response map[string]any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&response); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if response == nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
}
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
var result any
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
resultDecoder.UseNumber()
if err := resultDecoder.Decode(&result); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
response["resultJson"] = result
}
return deps.Out.PrintJSON(response)
}
func invalidWhiteboardToolResult(toolName string, err error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: "白板服务返回了无法解析的 JSON",
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
Operation: whiteboardServerID + "/" + toolName,
Cause: err,
}
}
@@ -1,310 +0,0 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
previousMarshal := whiteboardJSONMarshal
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
t.Fatalf("got %q", got)
}
whiteboardJSONMarshal = previousMarshal
previousPrepare := prepareWhiteboardCard
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
caller := &whiteboardTestCaller{}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
t.Fatalf("err=%v", err)
}
previousCompact := compactWhiteboardJSON
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
t.Fatal("expected compact error")
}
}
func TestDocWhiteboardInsertDryRun(t *testing.T) {
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func writeWhiteboardFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "invalid json", content: `{`},
{name: "trailing value", content: `{}` + ` {}`},
{name: "missing source", content: `{}`},
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "unknown field", content: `{"unknown":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing-file error")
}
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
t.Fatal("expected directory read error")
}
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
t.Fatal("expected malformed nodes array error")
}
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
if err != nil || !input.Overwrite || nodes != "[]" {
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
}
}
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
for _, name := range []string{"jq", "fields"} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(name, "", "")
if err := cmd.Flags().Set(name, ".result"); err != nil {
t.Fatal(err)
}
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
t.Fatal("expected rejected output filter")
}
})
}
responses := []string{
`{`,
`{} {}`,
`null`,
`{"resultJson":"{"}`,
`{"resultJson":"{} {}"}`,
}
for _, response := range responses {
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
t.Fatalf("response %q should fail", response)
}
}
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
t.Fatal(err)
}
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
t.Fatal(err)
}
}
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
tests := []struct {
name string
response string
attrs bool
}{
{name: "invalid response", response: `{`},
{name: "missing block", response: `{"blocks":[]}`},
{name: "non object block", response: `{"blocks":[1]}`},
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
installWhiteboardTestCaller(t, caller)
var err error
if test.attrs {
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
} else {
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
}
if err == nil {
t.Fatal("expected query validation error")
}
})
}
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
installWhiteboardTestCaller(t, caller)
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected caller error")
}
}
func TestWhiteboardCommandValidationBranches(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
for _, args := range [][]string{
{"query", "--node", "n"},
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
{"update", "--node", "n", "--part-id", "p"},
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
} {
cmd := newWhiteboardCommand()
cmd.PersistentFlags().String("jq", "", "")
cmd.PersistentFlags().String("fields", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["mode"] != "overwrite" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
if err := cmd.Execute(); err == nil {
t.Fatal("expected source error")
}
}
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
}}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
file := writeWhiteboardFixture(t, "svg")
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
for _, args := range [][]string{
{"media", "upload", "--node", "n"},
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
{"media", "upload", "--node", "n", "--file", t.TempDir()},
} {
cmd = newDocCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestDocMediaUploadRemainingBranches(t *testing.T) {
file := writeWhiteboardFixture(t, "svg")
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *whiteboardTestCaller
response string
}{
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
}}},
} {
t.Run(test.name, func(t *testing.T) {
installWhiteboardTestCaller(t, test.caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected upload error")
}
})
}
}
func TestDocWhiteboardInsertCallerError(t *testing.T) {
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
if index == 0 {
return errors.New("insert")
}
return nil
}}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected insert error")
}
}
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
if got := extractWhiteboardID(nil); got != "" {
t.Fatalf("got %q", got)
}
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
t.Fatalf("got %q", got)
}
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
var value any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
t.Fatal("expected trailing token error")
}
}
-411
View File
@@ -1,411 +0,0 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type whiteboardTestCall struct {
server string
tool string
args map[string]any
}
type whiteboardTestCaller struct {
dry bool
format string
err func(whiteboardTestCall, int) error
response func(whiteboardTestCall, int) string
calls []whiteboardTestCall
}
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
call := whiteboardTestCall{server: server, tool: tool, args: args}
c.calls = append(c.calls, call)
if c.err != nil {
if err := c.err(call, len(c.calls)-1); err != nil {
return nil, err
}
}
text := `{}`
if c.response != nil {
text = c.response(call, len(c.calls)-1)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *whiteboardTestCaller) Format() string { return c.format }
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
func (*whiteboardTestCaller) Fields() string { return "" }
func (*whiteboardTestCaller) JQ() string { return "" }
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
output := &bytes.Buffer{}
deps.Out.w = output
deps.Out.errW = &bytes.Buffer{}
return output
}
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
},
}
output := installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if _, ok := payload["resultJson"].(map[string]any); !ok {
t.Fatalf("resultJson was not decoded: %#v", payload)
}
}
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("err = %v, want cancellation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
t.Fatalf("args = %#v", caller.calls[0].args)
}
}
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
var blockID string
caller := &whiteboardTestCaller{
format: "json",
response: func(call whiteboardTestCall, index int) string {
if index == 0 {
var node []any
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
attrs := node[1].(map[string]any)
blockID = attrs["uuid"].(string)
return `{}`
}
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
encoded, _ := json.Marshal(jsonml)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
}
output := installWhiteboardTestCaller(t, caller)
previousDelays := whiteboardRetryDelays
whiteboardRetryDelays = nil
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
t.Fatalf("unexpected servers: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
if result["whiteboardId"] != "part-real" {
t.Fatalf("output = %#v", payload)
}
}
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
// 让回查桩可以用真实块 ID 组装响应。
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
t.Helper()
raw, _ := call.args["jsonml"].(string)
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
if len(node) < 2 {
t.Fatalf("jsonml node missing attrs: %q", raw)
}
attrs, _ := node[1].(map[string]any)
id, _ := attrs["uuid"].(string)
if id == "" {
t.Fatalf("jsonml node missing uuid: %q", raw)
}
return id
}
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
t.Helper()
previousDelays := whiteboardRetryDelays
previousSleep := whiteboardSleep
stub := make([]time.Duration, delays)
for i := range stub {
stub[i] = time.Millisecond
}
slept := 0
whiteboardRetryDelays = stub
whiteboardSleep = func(time.Duration) { slept++ }
t.Cleanup(func() {
whiteboardRetryDelays = previousDelays
whiteboardSleep = previousSleep
})
return func() int { return slept }
}
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
// 继续带着空 partId 调用 whiteboard query/update。
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
tests := []struct {
name string
queryErr error
queryBody func(blockID string) string
}{
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
{
name: "response missing blocks field",
queryBody: func(string) string { return `{"success":true}` },
},
{
name: "blocks field is not an array",
queryBody: func(string) string { return `{"blocks":{}}` },
},
{
name: "block jsonml unparsable",
queryBody: func(blockID string) string {
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
},
},
{
name: "card node without attrs",
queryBody: func(blockID string) string {
encoded, _ := json.Marshal(`[]`)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blockID := ""
caller := &whiteboardTestCaller{format: "json"}
caller.response = func(call whiteboardTestCall, index int) string {
if index == 0 {
blockID = whiteboardCardBlockID(t, call)
return `{}`
}
if test.queryBody == nil {
return `{}`
}
return test.queryBody(blockID)
}
if test.queryErr != nil {
caller.err = func(_ whiteboardTestCall, index int) error {
if index == 0 {
return nil
}
return test.queryErr
}
}
installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
t.Fatalf("err = %v, want fail-closed verification error", err)
}
if !strings.Contains(err.Error(), blockID) {
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
}
if len(caller.calls) != 2 || slept() != 0 {
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
len(caller.calls), slept())
}
})
}
}
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
// whiteboardId 为 null。
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(_ whiteboardTestCall, index int) string {
if index == 0 {
return `{}`
}
return `{"blocks":[]}`
},
}
output := installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("block-not-visible must stay a soft success: %v", err)
}
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
if len(caller.calls) != 4 || slept() != 2 {
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
whiteboardID, present := result["whiteboardId"]
if payload["success"] != true || !present || whiteboardID != nil {
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
}
if result["blockId"] == "" || result["blockId"] == nil {
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
}
}
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{
name: "ref-block with parent-block",
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
},
{
name: "where with parent-block",
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(test.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
}
if len(caller.calls) != 0 {
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
}
})
}
}
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
},
}
output := installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
t.Fatalf("output = %#v", payload)
}
}
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
uploadURL := "https://upload.example.test/secret-token"
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
},
}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
return fmt.Errorf("PUT %s: connection reset", uploadURL)
}
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
err := cmd.Execute()
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
t.Fatalf("err = %v, want redacted temporary upload URL", err)
}
}
-475
View File
@@ -1,475 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package localio owns safe local artifact publication shared by product
// shortcuts. Remote names and URLs are always treated as untrusted input.
package localio
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
const (
downloadTimeout = 10 * time.Minute
maxDownloadBytes = int64(512 << 20)
)
type downloadTempFile interface {
io.Writer
Sync() error
Close() error
}
var (
createDownloadTemp = createDownloadTempInRoot
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
localGetwd = os.Getwd
localAbs = filepath.Abs
localEvalSymlinks = filepath.EvalSymlinks
openDownloadRoot = os.OpenRoot
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
)
var downloadTempCounter atomic.Uint64
// DownloadOptions controls safe, atomic publication beneath BaseDir.
type DownloadOptions struct {
BaseDir string
Output string
PreferredName string
Headers map[string]string
}
// DownloadResult describes the published local artifact.
type DownloadResult struct {
AbsolutePath string
RelativePath string
SizeBytes int64
}
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
// output path without following symlink escapes, streams into a sibling temp
// file, fsyncs it, and atomically publishes the completed file.
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
}
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
}
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
parsed, err := ValidateDownloadURL(rawURL)
if err != nil {
return DownloadResult{}, err
}
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
if err != nil {
return DownloadResult{}, err
}
defer target.close()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
for key, value := range opts.Headers {
if strings.TrimSpace(key) != "" {
req.Header.Set(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if resp.ContentLength > maxBytes {
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
}
if err := target.verifyParent(); err != nil {
return DownloadResult{}, err
}
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
if err != nil {
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
}
cleanup := func() {
_ = tmp.Close()
_ = target.parentRoot.Remove(tmpName)
}
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
if copyErr == nil && size > maxBytes {
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
}
if copyErr == nil {
copyErr = tmp.Sync()
}
if closeErr := tmp.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
}
if err := target.verifyParent(); err != nil {
cleanup()
return DownloadResult{}, err
}
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
cleanup()
return DownloadResult{}, err
}
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
}
// ValidateOutput rejects absolute paths and portable `..` escapes.
func ValidateOutput(output string) error {
output = strings.TrimSpace(output)
if output == "" {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
}
portable := strings.ReplaceAll(output, "\\", "/")
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
}
clean := pathpkg.Clean(portable)
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
}
return nil
}
// ResolveOutputPath returns a symlink-safe destination below baseDir.
type downloadTarget struct {
baseRoot *os.Root
parentRoot *os.Root
parentInfo os.FileInfo
parentRelative string
destinationName string
absolutePath string
relativePath string
}
func (target *downloadTarget) close() {
_ = target.parentRoot.Close()
_ = target.baseRoot.Close()
}
func (target *downloadTarget) verifyParent() error {
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
if err != nil || !os.SameFile(target.parentInfo, current) {
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
}
return nil
}
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
if err != nil {
return "", "", err
}
defer target.close()
return target.absolutePath, target.relativePath, nil
}
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
if err := ValidateOutput(output); err != nil {
return nil, err
}
if strings.TrimSpace(baseDir) == "" {
var err error
baseDir, err = localGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
}
}
absBase, err := localAbs(baseDir)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
realBase, err := localEvalSymlinks(absBase)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
baseRoot, err := openDownloadRoot(realBase)
if err != nil {
return nil, fmt.Errorf("打开工作目录失败: %w", err)
}
fail := func(err error) (*downloadTarget, error) {
_ = baseRoot.Close()
return nil, err
}
rawOutput := strings.TrimSpace(output)
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
candidate := filepath.Clean(rawOutput)
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
directoryIntent = true
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
}
if directoryIntent {
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
}
parent := filepath.Dir(candidate)
if err := ensureSafeParent(baseRoot, parent); err != nil {
return fail(err)
}
parentRoot, err := openDownloadParent(baseRoot, parent)
if err != nil {
return fail(fmt.Errorf("固定输出目录失败: %w", err))
}
parentInfo, err := downloadRootStat(parentRoot, ".")
if err != nil {
_ = parentRoot.Close()
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
}
currentParent, err := downloadRootStat(baseRoot, parent)
if err != nil || !os.SameFile(parentInfo, currentParent) {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
}
destinationName := filepath.Base(candidate)
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
}
if info.IsDir() {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
}
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
} else if !errors.Is(statErr, os.ErrNotExist) {
_ = parentRoot.Close()
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
}
return &downloadTarget{
baseRoot: baseRoot,
parentRoot: parentRoot,
parentInfo: parentInfo,
parentRelative: parent,
destinationName: destinationName,
absolutePath: filepath.Join(realBase, candidate),
relativePath: candidate,
}, nil
}
// SafeFilename selects a portable basename from a preferred server name or URL.
func SafeFilename(preferredName, rawURL string) string {
if name := sanitizeFilename(preferredName); name != "" {
return name
}
if parsed, err := url.Parse(rawURL); err == nil {
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
if name := sanitizeFilename(decoded); name != "" {
return name
}
}
}
return "download"
}
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
}
return parsed, nil
}
func secureHTTPClient() *http.Client {
transport := &http.Transport{
// Do not use environment proxies here. DialContext must resolve and dial
// the validated download host itself; with a proxy it would receive the
// proxy address and could not enforce the target host's public-IP policy.
Proxy: nil,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := lookupDownloadIPs(ctx, host)
if err != nil {
return nil, err
}
for _, resolved := range ips {
if !publicIP(resolved.IP) {
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
}
}
// Dial the already validated address, not the hostname, to avoid a
// second DNS lookup opening a rebinding window.
var lastErr error
for _, resolved := range ips {
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("下载重定向次数超过上限")
}
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
return err
}
// net/http copies arbitrary request headers from the initial request to
// every redirect. Never forward service-provided download credentials to
// a different origin, even when both hosts are on the download allowlist.
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
req.Header = make(http.Header)
}
return nil
}
return client
}
func sameDownloadOrigin(left, right *url.URL) bool {
return downloadOrigin(left) == downloadOrigin(right)
}
func downloadOrigin(parsed *url.URL) string {
port := parsed.Port()
if port == "" {
port = "443"
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
}
func allowedDownloadHost(host string) bool {
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
}
func publicIP(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(addr) {
return false
}
}
return true
}
var nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
netip.MustParsePrefix("240.0.0.0/4"), // reserved
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
}
func ensureSafeParent(root *os.Root, parent string) error {
if parent == "." {
return nil
}
current := "."
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
current = filepath.Join(current, part)
info, statErr := downloadRootLstat(root, current)
if errors.Is(statErr, os.ErrNotExist) {
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("创建输出目录失败: %w", err)
}
info, statErr = downloadRootLstat(root, current)
}
if statErr != nil {
return fmt.Errorf("检查输出目录失败: %w", statErr)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
}
}
return nil
}
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, "", err
}
return file, name, nil
}
func publishTempFile(root *os.Root, tempName, destinationName string) error {
if err := downloadRootLink(root, tempName, destinationName); err != nil {
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
}
return fmt.Errorf("发布下载文件失败: %w", err)
}
if err := downloadRootRemove(root, tempName); err != nil {
return fmt.Errorf("清理下载临时文件失败: %w", err)
}
return nil
}
func sanitizeFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
return ""
}
return name
}
-726
View File
@@ -1,726 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
type failingBody struct{}
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (failingBody) Close() error { return nil }
type coverageTempFile struct {
file *os.File
writeErr error
syncErr error
closeErr error
onClose func()
}
func (f *coverageTempFile) Write(value []byte) (int, error) {
if f.writeErr != nil {
return 0, f.writeErr
}
return f.file.Write(value)
}
func (f *coverageTempFile) Name() string { return f.file.Name() }
func (f *coverageTempFile) Sync() error {
if f.syncErr != nil {
return f.syncErr
}
return f.file.Sync()
}
func (f *coverageTempFile) Close() error {
err := f.file.Close()
if f.onClose != nil {
f.onClose()
f.onClose = nil
}
if f.closeErr != nil {
return f.closeErr
}
return err
}
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
valid := []string{
"https://alidocs.dingtalk.com/file.docx",
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
}
for _, raw := range valid {
if _, err := ValidateDownloadURL(raw); err != nil {
t.Errorf("ValidateDownloadURL(%q): %v", raw, err)
}
}
invalid := []string{
"http://alidocs.dingtalk.com/file.docx",
"https://127.0.0.1/file.docx",
"https://evil.example/file.docx",
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
"https://user@alidocs.dingtalk.com/file.docx",
"https://alidocs.dingtalk.com:8443/file.docx",
}
for _, raw := range invalid {
if _, err := ValidateDownloadURL(raw); err == nil {
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
}
}
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
if publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = true", raw)
}
}
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
if !publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = false", raw)
}
}
}
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} {
if err := ValidateOutput(output); err == nil {
t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output)
}
}
base := t.TempDir()
destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "")
if err != nil {
t.Fatal(err)
}
realBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") {
t.Fatalf("destination=%q rel=%q", destination, rel)
}
if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("no-clobber error = %v", err)
}
outside := t.TempDir()
link := filepath.Join(base, "outside-link")
if err := os.Symlink(outside, link); err == nil {
if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") {
t.Fatalf("symlink escape error = %v", err)
}
}
if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" {
t.Errorf("SafeFilename traversal basename = %q", got)
}
for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} {
if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" {
t.Errorf("SafeFilename(%q) = %q", name, got)
}
}
}
func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) {
base := t.TempDir()
payload := "first payload"
requests := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" {
return nil, errors.New("unexpected host")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil
})}
result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client)
if err != nil {
t.Fatal(err)
}
if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) {
t.Fatalf("result = %#v", result)
}
got, err := os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("published content = %q, err=%v", got, err)
}
if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("second download error = %v", err)
}
if requests != 1 {
t.Fatalf("existing destination performed %d network requests, want 1 total", requests)
}
got, err = os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("no-clobber content = %q, err=%v", got, err)
}
}
func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) {
base := t.TempDir()
for _, tc := range []struct {
name string
contentLength int64
}{
{name: "declared", contentLength: 6},
{name: "streamed", contentLength: -1},
} {
t.Run(tc.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("123456")),
Header: make(http.Header),
ContentLength: tc.contentLength,
}, nil
})}
output := tc.name + ".bin"
if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: output,
}, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") {
t.Fatalf("oversized download error = %v", err)
}
if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("oversized destination exists: %v", err)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-download-") {
t.Fatalf("oversized download left temp file %q", entry.Name())
}
}
})
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
if err := os.Rename(parent, original); err != nil {
t.Skipf("platform cannot replace an open directory: %v", err)
}
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
if renameErr := os.Rename(parent, original); renameErr != nil {
t.Skipf("platform cannot replace an open directory: %v", renameErr)
}
if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil {
t.Fatal(mkdirErr)
}
}}, name, nil
})
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/file.bin"
if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil {
t.Fatal("invalid URL download succeeded")
}
if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil {
t.Fatal("unsafe output download succeeded")
}
clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })}
statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" {
t.Errorf("headers = %#v", req.Header)
}
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil
})}
bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil {
t.Fatal("transport error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil {
t.Fatal("HTTP status error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil {
t.Fatal("body read error was ignored")
}
okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
for _, tc := range []struct {
name string
makeTemp func(*os.Root) (downloadTempFile, string, error)
}{
{"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
{"sync", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil
}},
{"close", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil
}},
{"publish-race", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if createErr == nil {
_ = file.Close()
}
}}, name, nil
}},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &createDownloadTemp, tc.makeTemp)
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil {
t.Fatalf("%s failure was ignored", tc.name)
}
})
}
}
func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
client := secureHTTPClient()
transport := client.Transport.(*http.Transport)
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
t.Fatal("redirect limit accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
t.Fatal("unsafe redirect accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
t.Fatal(err)
}
if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil {
t.Fatal("bad address dial succeeded")
}
t.Run("lookup error", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("lookup error ignored")
}
})
t.Run("private answer", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
})
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("private DNS answer accepted")
}
})
t.Run("public dial fallback and success", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
})
left, right := net.Pipe()
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
calls := 0
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, errors.New("first")
}
return left, nil
})
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
t.Fatal(err)
} else {
_ = conn.Close()
}
})
t.Run("all public dials fail", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
})
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("dial failure ignored")
}
})
base := t.TempDir()
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("missing base succeeded")
}
dir := filepath.Join(base, "directory")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
for _, output := range []string{".", "directory/", "directory"} {
if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil {
t.Errorf("directory output %q: %v", output, err)
}
}
if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
targetDir := filepath.Join(base, "target-dir")
_ = os.Mkdir(targetDir, 0o700)
if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(base, "existing.txt")
_ = os.WriteFile(targetFile, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("existing destination error = %v", err)
}
link := filepath.Join(base, "target-link")
if err := os.Symlink(targetFile, link); err == nil {
if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("symlink destination accepted")
}
}
fileParent := filepath.Join(base, "file-parent")
_ = os.WriteFile(fileParent, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("file parent accepted")
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = root.Close() })
if err := ensureSafeParent(root, "../escape"); err == nil {
t.Fatal("escaping parent accepted")
}
if err := ensureSafeParent(root, "."); err != nil {
t.Fatal(err)
}
source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_, _ = source.WriteString("x")
_ = source.Close()
destination := filepath.Join(base, "publish.txt")
_ = os.WriteFile(destination, []byte("old"), 0o600)
if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil {
t.Fatal("publish existing destination succeeded")
}
if err := publishTempFile(root, "missing-source", "new.txt"); err == nil {
t.Fatal("publish missing source succeeded")
}
symlinkDestination := filepath.Join(base, "publish-link")
if err := os.Symlink(destination, symlinkDestination); err == nil {
if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil {
t.Fatal("publish to symlink succeeded")
}
}
closedRoot, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
_ = closedRoot.Close()
if _, _, err := createDownloadTempInRoot(closedRoot); err == nil {
t.Fatal("temp creation in closed root succeeded")
}
for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} {
_ = sanitizeFilename(name)
}
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
_ = SafeFilename("", "://bad")
_ = publicIP(net.IP{1, 2, 3})
}
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
transport := secureHTTPClient().Transport.(*http.Transport)
if transport.Proxy != nil {
t.Fatal("secure download client accepted an environment proxy")
}
}
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
client := secureHTTPClient()
original := &http.Request{
URL: mustURL(t, "https://download.dingtalk.com/source"),
Header: http.Header{
"X-Oss-Security-Token": []string{"credential-a"},
"X-Download-Auth": []string{"credential-b"},
},
}
sameOrigin := &http.Request{
URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if sameOrigin.Header.Get("X-Oss-Security-Token") == "" {
t.Fatal("same-origin redirect unexpectedly stripped request headers")
}
crossOrigin := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if len(crossOrigin.Header) != 0 {
t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header))
}
multiHop := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil {
t.Fatal(err)
}
if len(multiHop.Header) != 0 {
t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header))
}
}
func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/x"
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil {
t.Fatal("cancelled default client download succeeded")
}
t.Run("getwd", func(t *testing.T) {
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
_, _, _ = ResolveOutputPath("", "x", validURL, "")
})
t.Run("abs", func(t *testing.T) {
testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("eval base", func(t *testing.T) {
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("open base", func(t *testing.T) {
testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") })
_, _, _ = ResolveOutputPath(base, "new/target", validURL, "")
})
t.Run("lstat after mkdir", func(t *testing.T) {
calls := 0
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "new-after" {
calls++
if calls > 1 {
return nil, errors.New("after mkdir")
}
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "")
})
t.Run("open parent", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") })
_, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "")
})
t.Run("parent stat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "." {
return nil, errors.New("parent stat")
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "")
})
t.Run("parent identity", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil {
t.Fatal(err)
}
otherInfo, err := os.Stat(t.TempDir())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "parent-identity" {
return otherInfo, nil
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "")
})
t.Run("destination directory", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil {
t.Fatal(err)
}
dirInfo, err := os.Stat(base)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return dirInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "")
})
t.Run("destination symlink", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "coverage-link")
if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
linkInfo, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return linkInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "")
})
t.Run("destination lstat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return nil, errors.New("destination lstat")
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "")
})
t.Run("unsafe parent type", func(t *testing.T) {
filePath := filepath.Join(base, "unsafe-parent")
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := ensureSafeParent(root, "unsafe-parent"); err == nil {
t.Fatal("regular file accepted as output parent")
}
})
t.Run("publish remove", func(t *testing.T) {
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_ = file.Close()
testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") })
if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil {
t.Fatal("publish remove error ignored")
}
})
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return parsed
}
-30
View File
@@ -1,30 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// Package profilectx owns the process-local profile selector without importing
// authentication or transport packages.
package profilectx
import (
"strings"
"sync"
)
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// Set records the explicit profile selector for the current process.
func Set(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// Get returns the explicit process-local profile selector.
func Get() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
-20
View File
@@ -1,20 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package profilectx
import "testing"
func TestCrossPlatformCoverageSetAndGet(t *testing.T) {
t.Cleanup(func() { Set("") })
Set(" fixture-profile ")
if got := Get(); got != "fixture-profile" {
t.Fatalf("Get() = %q, want fixture-profile", got)
}
Set("")
if got := Get(); got != "" {
t.Fatalf("Get() after reset = %q, want empty", got)
}
}
+1 -75
View File
@@ -36,16 +36,6 @@ func FromShortcut(s Shortcut) corecmd.Spec {
if !safetySpecDeclared(safety) {
safety = shortcutSafetySpec(s.risk())
}
declaredContract := s.Contract
if !declaredContract.Empty() && len(s.Aliases) > 0 && len(declaredContract.Identity.Aliases) == 0 {
declaredContract.Identity.Aliases = make([]string, 0, len(s.Aliases))
for _, alias := range s.Aliases {
declaredContract.Identity.Aliases = append(
declaredContract.Identity.Aliases,
s.Service+" "+strings.TrimSpace(alias),
)
}
}
return corecmd.Spec{
Use: s.Command,
Short: s.Description,
@@ -57,12 +47,11 @@ func FromShortcut(s Shortcut) corecmd.Spec {
Flags: fromShortcutFlags(s.Flags),
Constraints: fromShortcutConstraints(s.Constraints),
Safety: safety,
Contract: declaredContract,
Contract: s.Contract,
// Preserve the shipped Shortcut Catalog provenance: Cobra remains the
// source for type/default/usage, while command adds Required/Enum/rules.
ParameterProjection: corecmd.ProjectCobraParameters,
Validate: fromShortcutValidate(s),
PostMount: fromShortcutPostMount(s),
// Multi-step body: command stays backend-agnostic, so the shortcut's own
// RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is
// built here from the Ctx's command.
@@ -76,57 +65,6 @@ func FromShortcut(s Shortcut) corecmd.Spec {
}
}
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
hasVisibleFlagAliases := false
for _, flag := range s.Flags {
if flag.AliasesVisible && len(flag.Aliases) > 0 {
hasVisibleFlagAliases = true
break
}
}
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" && !hasVisibleFlagAliases {
return nil
}
return func(cmd *cobra.Command) {
cmd.Aliases = append([]string(nil), s.Aliases...)
for _, flag := range s.Flags {
if !flag.AliasesVisible {
continue
}
for _, alias := range flag.Aliases {
if mounted := cmd.Flags().Lookup(alias); mounted != nil {
mounted.Hidden = false
}
}
}
name := strings.TrimSpace(s.SinglePositionalAliasFor)
if name == "" {
return
}
cmd.Args = func(cmd *cobra.Command, args []string) error {
if err := cobra.MaximumNArgs(1)(cmd, args); err != nil {
return err
}
if len(args) == 0 {
return nil
}
flag := cmd.Flags().Lookup(name)
if flag == nil {
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s positional alias flag --%s is not registered",
s.Service, s.Command, name))
}
if flag.Changed {
return apperrors.NewValidation(fmt.Sprintf("位置参数与 --%s 不能同时提供", name))
}
if err := cmd.Flags().Set(name, args[0]); err != nil {
return apperrors.NewValidation(fmt.Sprintf("位置参数无法写入 --%s: %v", name, err))
}
return nil
}
}
}
func safetySpecDeclared(safety contract.SafetySpec) bool {
return strings.TrimSpace(safety.Effect) != "" ||
strings.TrimSpace(safety.Risk) != "" ||
@@ -134,16 +72,6 @@ func safetySpecDeclared(safety contract.SafetySpec) bool {
strings.TrimSpace(safety.Idempotency) != ""
}
// EffectiveSafety returns the exact safety declaration used by the runtime and
// ContractFinal. Management/listing projections must use this instead of
// re-inferring confirmation from the legacy Risk enum.
func EffectiveSafety(s Shortcut) contract.SafetySpec {
if safetySpecDeclared(s.Safety) {
return s.Safety
}
return shortcutSafetySpec(s.risk())
}
func shortcutExamples(tips []string) string {
if len(tips) == 0 {
return ""
@@ -205,7 +133,6 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
for _, f := range flags {
out = append(out, corecmd.FlagSpec{
Name: f.Name,
Shorthand: f.Shorthand,
Usage: flagHelp(f),
Kind: fromShortcutFlagKind(f.Type),
Default: f.Default,
@@ -215,7 +142,6 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
ValidationMode: corecmd.ValidationShortcut,
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
Enum: append([]string(nil), f.Enum...),
Aliases: append([]string(nil), f.Aliases...),
})
}
return out
+2 -79
View File
@@ -20,7 +20,6 @@ import (
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// TestCrossPlatformCoverageFromShortcutMapsSharedBase verifies FromShortcut
@@ -34,7 +33,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
Hidden: true,
Tips: []string{"dws chat +demo --name a"},
Flags: []Flag{
{Name: "name", Shorthand: "n", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "count", Type: FlagInt, Desc: "数量"},
{Name: "flag", Type: FlagBool, Desc: "开关"},
{Name: "ids", Type: FlagStringSlice, Desc: "列表"},
@@ -66,13 +65,6 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
}
if got := EffectiveSafety(Shortcut{Risk: RiskWrite}); got.Effect != "write" || got.Confirmation != "user_required" {
t.Fatalf("legacy effective safety = %#v", got)
}
explicit := contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
if got := EffectiveSafety(Shortcut{Risk: RiskHighWrite, Safety: explicit}); got != explicit {
t.Fatalf("explicit effective safety = %#v, want %#v", got, explicit)
}
if cs.Orchestrate == nil {
t.Fatal("multi-step Execute must project into Orchestrate")
}
@@ -93,7 +85,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
name := cs.Flags[0]
if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" ||
if name.Name != "name" || !name.Required || name.Default != "d" ||
!name.Hidden || name.ValidationMode != corecmd.ValidationShortcut ||
name.RequiredError != "缺少必填参数 --name:名称" ||
strings.Join(name.Enum, ",") != "a,b" {
@@ -128,75 +120,6 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T) {
executed := ""
s := Shortcut{
Service: "chat",
Command: "+search",
Aliases: []string{"+search-group"},
SinglePositionalAliasFor: "query",
Description: "搜索群",
Intent: "按名称搜索群",
Contract: corecmd.ContractDecl{
Description: "按名称搜索群",
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "test composite",
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索群", UseWhen: []string{"按名称搜索群"}, AvoidWhen: []string{"不要用于成员搜索"}, Examples: []string{"dws chat +search --query demo"},
},
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
},
},
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true, Aliases: []string{"keyword"}, AliasesVisible: true}},
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
}
spec := FromShortcut(s)
if got := spec.Contract.Identity.Aliases; len(got) != 1 || got[0] != "chat +search-group" {
t.Fatalf("contract aliases = %#v", got)
}
cmd := mount(s)
if !cmd.HasAlias("+search-group") {
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
}
if alias := cmd.Flags().Lookup("keyword"); alias == nil || alias.Hidden {
t.Fatalf("historically public flag alias = %#v, want visible", alias)
}
cmd.SetArgs([]string{"项目群"})
if err := cmd.Execute(); err != nil || executed != "项目群" {
t.Fatalf("positional execute err=%v value=%q", err, executed)
}
conflict := mount(s)
conflict.SetArgs([]string{"项目群", "--query", "另一个群"})
if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "不能同时提供") {
t.Fatalf("positional/flag conflict err = %v", err)
}
tooMany := mount(s)
tooMany.SetArgs([]string{"one", "two"})
if err := tooMany.Execute(); err == nil {
t.Fatal("multiple positional aliases unexpectedly accepted")
}
missing := mount(Shortcut{
Service: "chat", Command: "+missing", Description: "missing",
SinglePositionalAliasFor: "query", Execute: func(*RuntimeContext) error { return nil },
})
missing.SetArgs([]string{"value"})
if err := missing.Execute(); err == nil || !strings.Contains(err.Error(), "is not registered") {
t.Fatalf("missing positional flag err = %v", err)
}
invalid := mount(Shortcut{
Service: "chat", Command: "+invalid", Description: "invalid",
SinglePositionalAliasFor: "query", Flags: []Flag{{Name: "query", Type: FlagInt}}, Execute: func(*RuntimeContext) error { return nil },
})
invalid.SetArgs([]string{"not-an-int"})
if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "无法写入") {
t.Fatalf("invalid positional value err = %v", err)
}
}
// TestCrossPlatformCoverageFromShortcutMatchesMountSurface pins the live
// adapter surface: flag set (names/types/usage) and rendered Long must agree.
// This catches a double-rendered 参数约束 or lost flagHelp decoration.
+3 -4
View File
@@ -12,10 +12,9 @@
// limitations under the License.
// Package builtin aggregates all built-in shortcut service packages via blank
// imports so their init() registrations run, applies the reviewed semantic and
// public-catalog decorations in the core registry, then re-exports the compiled
// cobra commands. The host application depends only on this package, keeping
// the service packages free to import the core shortcut package without a cycle.
// imports so their init() registrations run, then re-exports the compiled cobra
// commands. The host application depends only on this package, keeping the
// service packages free to import the core shortcut package without a cycle.
//
// Add a blank import here when a new service package is generated under
// internal/shortcut/<service>/.
@@ -27,7 +27,7 @@ import (
// TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential
// guard for the live mount migration. It derives the historical Cobra surface
// directly from each Shortcut declaration and checks the command-built tree.
func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
mounted := map[string]*cobra.Command{}
for _, service := range builtin.BaseCommands() {
for _, command := range service.Commands() {
@@ -83,18 +83,6 @@ func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t
spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden)
}
assertShortcutDefault(t, command, spec, flag)
for _, alias := range flag.Aliases {
declaredFlags[alias] = flag
gotAlias := command.Flags().Lookup(alias)
if gotAlias == nil {
t.Errorf("%s %s: flag alias --%s is not mounted", spec.Service, spec.Command, alias)
continue
}
wantHidden := !flag.AliasesVisible
if gotAlias.Hidden != wantHidden {
t.Errorf("%s %s: flag alias --%s hidden = %v, want %v", spec.Service, spec.Command, alias, gotAlias.Hidden, wantHidden)
}
}
}
command.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Name == "help" {
@@ -1,83 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_doc.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "doc" {
t.Fatalf("semantic catalog service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
if len(registered) != 47 || len(source.Shortcuts) != 47 {
t.Fatalf("registered/catalog = %d/%d, want 47/47", len(registered), len(source.Shortcuts))
}
var missing, stale []string
public, hidden := 0, 0
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || record.SemanticDelta != item.SemanticDelta || record.Disposition != item.Disposition {
t.Errorf("%s: reviewed semantic delivery mismatch", command)
}
if got := shortcut.InPublicCatalog("doc", command); got != record.Public || item.Hidden == record.Public {
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
}
if record.Public {
public++
if item.Contract.Empty() {
t.Errorf("%s: public Doc shortcut has empty Contract", command)
}
} else {
hidden++
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 45 || hidden != 2 {
t.Fatalf("public/hidden = %d/%d, want 45/2", public, hidden)
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
if item.Disposition != shortcut.DispositionAliasInternal || item.PrimaryCommand != primary {
t.Errorf("%s compatibility routing = %s/%s, want alias_internal/%s", command, item.Disposition, item.PrimaryCommand, primary)
}
}
}
+17 -41
View File
@@ -5,9 +5,6 @@
package chat
import (
"fmt"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -31,15 +28,14 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
})
}
return rt.Output(map[string]any{
"contractVersion": "im.batch-write.v1",
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
})
}
@@ -60,33 +56,13 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
}
succeeded = append(succeeded, entry)
}
result := map[string]any{
"contractVersion": "im.batch-write.v1",
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
}
if err := rt.Output(result); err != nil {
return err
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("批量执行 %s 失败:%d/%d 个目标未完成", tool, len(failures), len(items)),
apperrors.WithOperation(product+"/"+tool),
apperrors.WithReason("batch_write_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"partial": len(succeeded) > 0,
}),
)
}
return nil
return rt.Output(map[string]any{
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
})
}
+2 -2
View File
@@ -228,8 +228,8 @@ func botFindProject(data map[string]any) []map[string]any {
// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server).
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
BotSearch,
BotFind,
)...)
)
}
+30 -140
View File
@@ -15,7 +15,6 @@ package chat
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
@@ -89,8 +88,8 @@ var ConversationSetTop = shortcut.Shortcut{
Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
},
Constraints: []shortcut.Constraint{
@@ -165,7 +164,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{
Command: "+conversation-mute-at-all",
Product: "im",
Description: "关闭/开启 @所有人消息提醒",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -186,7 +185,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{
Command: "+conversation-mute-red-envelope",
Product: "im",
Description: "关闭/开启红包消息提醒",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -285,8 +284,8 @@ var ConversationList = shortcut.Shortcut{
Service: "chat",
Command: "+conversation-list",
Product: "im",
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。",
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -300,143 +299,47 @@ var ConversationList = shortcut.Shortcut{
CLIPath: "chat +conversation-list",
PrimaryCLIPath: "chat +conversation-list",
},
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{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"},
AgentSummary: "分页获取当前用户的全部会话列表(单聊+群聊)",
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +conversation-list --limit 50"},
},
},
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
{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"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +conversation-list --limit 50`,
`dws chat +conversation-list --page-all --limit 100`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1-100 之间;读取全部会话请使用 --page-all")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
return nil
},
Tips: []string{`dws chat +conversation-list --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
cursor := int64(rt.Int("cursor"))
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
}
convs := make([]map[string]any, 0)
seenConversations := map[string]bool{}
seenCursors := map[int64]bool{cursor: true}
pagesFetched := 0
complete := false
hasMore := false
nextCursor := int64(0)
failures := make([]map[string]any, 0)
for pagesFetched < pageLimit {
params := map[string]any{"limit": rt.Int("limit")}
if cursor > 0 {
params["cursor"] = cursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
if pagesFetched == 0 {
return err
}
failures = append(failures, map[string]any{"stage": "conversation-page", "cursor": cursor, "error": err.Error()})
break
}
pagesFetched++
for _, conversation := range conversationListProject(data) {
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
if id != "" && id != "<nil>" {
if seenConversations[id] {
continue
}
seenConversations[id] = true
}
convs = append(convs, conversation)
}
page := chatmsg.Pagination(data)
hasMoreValue, known := page["hasMore"].(bool)
hasMore = hasMoreValue
if !known {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
break
}
if !hasMore {
complete = true
break
}
nextCursor, err = conversationPaginationCursor(page["nextCursor"])
if err != nil || nextCursor == 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "hasMore=true 但 nextCursor 缺失、无效或未前进"})
break
}
if !rt.Bool("page-all") {
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
if rt.Int("cursor") > 0 {
params["cursor"] = rt.Int("cursor")
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
payload := map[string]any{
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
return err
}
convs := conversationListProject(data)
payload := map[string]any{"count": len(convs), "conversations": convs}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
}
func conversationPaginationCursor(value any) (int64, error) {
switch typed := value.(type) {
case int:
return int64(typed), nil
case int64:
return typed, nil
case float64:
return int64(typed), nil
case string:
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
// conversationListProject reshapes the raw list_all_conversations response into a
// clean conversation list — clean output projection. Both the list
// container and the per-item field names are probed defensively across candidate
@@ -476,12 +379,12 @@ func conversationListResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
}
}
@@ -489,19 +392,6 @@ func conversationListResolveList(data map[string]any) []any {
return []any{}
}
// unwrapConversationTuple handles gateway responses shaped as
// result:[conversationList,nextCursor,hasMore] while leaving ordinary arrays
// untouched. This prevents the first list from being mistaken for one row.
func unwrapConversationTuple(values []any) []any {
if len(values) == 0 {
return values
}
if nested, ok := values[0].([]any); ok {
return nested
}
return values
}
// conversationListFirst returns the first present candidate key's value.
func conversationListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
@@ -686,12 +576,12 @@ func conversationListTopResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
}
}
@@ -1180,7 +1070,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
ConversationInfo,
ConversationSetTop,
ConversationMute,
@@ -1201,5 +1091,5 @@ func init() {
CategoryRename,
CategoryAddConversation,
CategoryRemoveConversation,
)...)
)
}
@@ -14,15 +14,13 @@
package chat
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
data := map[string]any{
"result": map[string]any{
"items": []any{
@@ -77,7 +75,7 @@ func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType
}
}
func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) {
func TestConversationListTopRejectsInvalidType(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -89,82 +87,3 @@ func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T
t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool)
}
}
func TestCrossPlatformCoverageConversationListProjectUnwrapsGatewayTuple(t *testing.T) {
data := map[string]any{
"result": []any{
[]any{map[string]any{"openConversationId": "cid-1", "title": "项目群"}},
float64(2),
true,
},
}
if got := conversationListProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
t.Fatalf("conversation tuple projection = %#v", got)
}
if got := conversationListTopProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
t.Fatalf("top tuple projection = %#v", got)
}
}
func TestCrossPlatformCoverageConversationListPageAllFollowsTypedCursor(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"conversationList":[{"openConversationId":"cid-2","title":"二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+conversation-list", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != int64(2) {
t.Fatalf("calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one page", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["hasMore"] != true || payload["nextCursor"] != float64(2) {
t.Fatalf("pagination payload = %#v", payload)
}
}
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list"})
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) {
t.Fatalf("deduplicated payload = %#v", payload)
}
}
+19 -61
View File
@@ -23,19 +23,16 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"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"
)
// ChatSearch searches groups by keyword (search_groups on the im server).
var ChatSearch = shortcut.Shortcut{
Service: "chat",
Command: "+chat-search",
Aliases: []string{"+chat-group-search", "+search-group"},
SinglePositionalAliasFor: "query",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Risk: shortcut.RiskRead,
Service: "chat",
Command: "+chat-search",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
@@ -150,7 +147,7 @@ var ChatInviteURL = shortcut.Shortcut{
Command: "+chat-invite-url",
Product: "im",
Description: "获取群邀请链接",
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。",
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -172,32 +169,20 @@ var ChatInviteURL = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "获取群邀请链接",
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。"},
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-invite-url --group <openConversationId>"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
},
Tips: []string{
`dws chat +chat-invite-url --group <openConversationId>`,
`dws chat +chat-invite-url --group "项目群"`,
},
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID, err := resolveStableOrNamedChat(rt)
if err != nil {
return err
}
params := map[string]any{
"openConversationId": groupID,
"cid": groupID,
"openConversationId": rt.Str("group"),
"cid": rt.Str("group"),
}
if rt.Changed("expires-seconds") {
params["expiresSeconds"] = rt.Int("expires-seconds")
@@ -792,7 +777,7 @@ var ChatBots = shortcut.Shortcut{
Command: "+chat-bots",
Product: "bot",
Description: "查看群内所有机器人",
Intent: "当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。",
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -814,29 +799,17 @@ var ChatBots = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查看群内所有机器人",
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。"},
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-bots --group <openConversationId>"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
},
Tips: []string{
`dws chat +chat-bots --group <openConversationId>`,
`dws chat +chat-bots --group "项目群"`,
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID, err := resolveStableOrNamedChat(rt)
if err != nil {
return err
}
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": groupID})
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
if err != nil {
return err
}
@@ -845,21 +818,6 @@ var ChatBots = shortcut.Shortcut{
},
}
// resolveStableOrNamedChat gives read-only group shortcuts one safe target
// contract. Stable cid values bypass search; natural names always go through
// the shared exact-match, full-pagination and ambiguity rules.
func resolveStableOrNamedChat(rt *shortcut.RuntimeContext) (string, error) {
resolved, err := targetresolver.ResolveChatTarget(
rt,
strings.TrimSpace(rt.Str("group")),
strings.TrimSpace(rt.StrFirst("chat-query", "group-query")),
)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
// chatBotsProject reshapes list_group_bots into a clean bot list
// ({openBotId, name}) — clean output projection. List container and
// per-item field names are probed defensively across candidate keys so shape
@@ -1503,7 +1461,7 @@ var ChatRoleQueryUser = shortcut.Shortcut{
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
ChatSearch,
ChatMembersGet,
ChatTransferOwner,
@@ -1533,5 +1491,5 @@ func init() {
ChatRoleSetUser,
ChatRoleRemoveUser,
ChatRoleQueryUser,
)...)
)
}
+67 -149
View File
@@ -21,8 +21,8 @@ import (
"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"
)
@@ -110,8 +110,8 @@ var MessagesSendByWebhook = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-by-webhook",
Product: "bot",
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
Intent: "只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用",
Description: "自定义机器人 Webhook 发送群消息",
Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -125,15 +125,15 @@ var MessagesSendByWebhook = shortcut.Shortcut{
CLIPath: "chat +messages-send-by-webhook",
PrimaryCLIPath: "chat +messages-send-by-webhook",
},
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
Description: "自定义机器人 Webhook 发送群消息",
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: "兼容旧入口的自定义机器人 Webhook 群消息发送",
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
AgentSummary: "自定义机器人 Webhook 发送群消息",
UseWhen: []string{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
},
@@ -172,56 +172,17 @@ var MessagesRecall = shortcut.Shortcut{
Command: "+messages-recall",
Product: "im",
Description: "撤回当前用户发送的消息",
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息。推荐同时传会话 openConversationId 和消息 openMessageId;若只传一个消息 ID,CLI 会先只读查询消息详情并补齐会话 ID。兼容 --message-id/--message-ids 的单值写法。",
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId;省略时从消息详情解析"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId;一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-id 的兼容单值别名;不支持批量撤回", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "message-ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"msg-id"}, Description: "一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
},
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
Validate: func(rt *shortcut.RuntimeContext) error {
messageIDs := uniqueShortcutStrings(append(
[]string{rt.StrFirst("msg-id", "message-id")},
rt.StrSlice("message-ids")...,
))
if len(messageIDs) != 1 {
return apperrors.NewValidation("撤回一次只接受一个消息 ID;请通过 --msg-id 或单值 --message-ids 传入")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
messageIDs := uniqueShortcutStrings(append(
[]string{rt.StrFirst("msg-id", "message-id")},
rt.StrSlice("message-ids")...,
))
messageID := messageIDs[0]
conversationID := strings.TrimSpace(rt.StrFirst("conversation-id", "group", "id", "chat"))
if conversationID == "" {
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": []string{messageID}})
if err != nil {
return err
}
messages := listMessagesResolveMaps(data)
if len(messages) == 0 {
return apperrors.NewValidation("无法根据消息 ID 查询到会话;请补充 --conversation-id")
}
conversationID = strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(messages[0])))
if conversationID == "" || conversationID == "<nil>" {
return apperrors.NewValidation("消息详情未返回会话 ID;请补充 --conversation-id")
}
}
return rt.CallMCP("recall_message", map[string]any{
"openConversationId": conversationID,
"openMessageId": messageID,
"openConversationId": rt.Str("conversation-id"),
"openMessageId": rt.Str("msg-id"),
})
},
}
@@ -353,22 +314,46 @@ func listMessageProjectOne(m map[string]any) map[string]any {
}
func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any {
row := chatmsg.ProjectMessageV1(m, includeReactions)
// The established mget/list projection omits absent scalar fields; keep
// that wire behavior even though the shared chat/search view retains them.
for _, key := range []string{"sender", "text", "createTime"} {
if row[key] == nil {
delete(row, key)
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if text := chatmsg.Text(m); text != nil {
row["text"] = text
}
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
row["conversationId"] = conversationID
}
if threadID := chatmsg.ThreadID(m); threadID != nil {
row["threadId"] = threadID
}
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
row["updateTime"] = updateTime
}
if includeReactions {
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
row["reactions"] = reactions
}
}
// Keep the historical mget/list msgType alias while adding the canonical
// messageType field from MessageViewV1.
if messageType := chatmsg.MessageType(m); messageType != nil {
row["msgType"] = messageType
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
return listMessageProjectOneWithReactions(item, includeReactions)
}); len(forwarded) > 0 {
}
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
@@ -680,35 +665,20 @@ var MessagesMget = shortcut.Shortcut{
}
}
notFound := make([]string, 0)
failures := make([]map[string]any, 0)
for _, id := range ids {
if !found[id] {
notFound = append(notFound, id)
failures = append(failures, map[string]any{
"stage": "mget",
"messageId": id,
"error": "下层未返回该消息",
})
}
}
payload := map[string]any{
"contractVersion": chatmsg.MessageListContractVersion,
"requestedCount": len(ids),
"foundCount": len(ids) - len(notFound),
"notFoundCount": len(notFound),
"notFoundMessageIds": notFound,
"messages": messages,
"complete": len(notFound) == 0,
"hasMore": false,
"nextCursor": "",
"paginationKnown": true,
"pagesFetched": 1,
"enrichedCount": 0,
"failedCount": len(failures),
"failures": failures,
}
if rt.Bool("download-resources") {
AttachMessageResourceDownloads(payload, DownloadMessageResources(rt, rawMessages, ""))
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
}
return rt.Output(payload)
},
@@ -720,7 +690,7 @@ func MessageResourceDownloadFlags() []shortcut.Flag {
return []shortcut.Flag{
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的本地输出文件(默认拒绝)"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
}
}
@@ -932,47 +902,6 @@ func DownloadMessageResources(
}
}
// AttachMessageResourceDownloads publishes the download ledger and folds any
// resource failure into the task-level completeness contract without dropping
// successfully read messages or downloaded files.
func AttachMessageResourceDownloads(payload, ledger map[string]any) {
payload["resourceDownloads"] = ledger
failed := messageLedgerInt(ledger["failedCount"])
if failed == 0 {
return
}
payload["complete"] = false
payload["failedCount"] = messageLedgerInt(payload["failedCount"]) + failed
taskFailures, _ := payload["failures"].([]map[string]any)
resourceFailures, _ := ledger["failures"].([]map[string]any)
for _, failure := range resourceFailures {
row := make(map[string]any, len(failure)+1)
row["stage"] = "resource-download"
for key, value := range failure {
row[key] = value
}
taskFailures = append(taskFailures, row)
}
payload["failures"] = taskFailures
}
func messageLedgerInt(value any) int {
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
default:
return 0
}
}
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
if !used[strings.ToLower(filename)] {
return filename
@@ -1239,7 +1168,7 @@ var MessagesSendCard = shortcut.Shortcut{
Command: "+messages-send-card",
Product: "im",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1261,7 +1190,7 @@ var MessagesSendCard = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
UseWhen: []string{"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"},
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
Examples: []string{
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
@@ -1292,7 +1221,7 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
if status := rt.Int("flow-status"); status < 1 || status > 5 {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
if rt.Changed("flow-status") && rt.Str("content") == "" {
@@ -1324,12 +1253,11 @@ var MessagesSendCard = shortcut.Shortcut{
status := rt.Int("flow-status")
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"tool": "create_and_send_card",
@@ -1363,12 +1291,11 @@ var MessagesSendCard = shortcut.Shortcut{
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
},
}
@@ -1435,7 +1362,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
Command: "+messages-update-card",
Product: "im",
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
Intent: "当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。",
Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1457,7 +1384,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
},
@@ -1465,18 +1392,9 @@ var MessagesUpdateCard = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true},
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间", Required: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"flow-status"}, Description: "--flow-status 必须在 1-5 之间"},
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true},
},
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
Validate: func(rt *shortcut.RuntimeContext) error {
if !validCardFlowStatus(rt.Int("flow-status")) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_streaming_card", map[string]any{
"bizId": rt.Str("biz-id"),
@@ -1817,7 +1735,7 @@ var MessagesUnsetTop = shortcut.Shortcut{
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
MessagesSendByBot,
MessagesBatchSendByBot,
MessagesSendByWebhook,
@@ -1846,5 +1764,5 @@ func init() {
MessagesListPin,
MessagesSetTop,
MessagesUnsetTop,
)...)
)
}
@@ -20,7 +20,7 @@ import (
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestCrossPlatformCoverageListMessageProjectOne(t *testing.T) {
func TestListMessageProjectOne(t *testing.T) {
// full field mapping + forwarded expansion; an encrypted body is marked (no
// cross-conversation recovery), not leaked as base64.
row := listMessageProjectOne(map[string]any{
@@ -75,35 +75,7 @@ func TestCrossPlatformCoverageListMessageProjectOne(t *testing.T) {
}
}
func TestCrossPlatformCoverageAttachMessageResourceDownloadsPreservesMessagesAndMarksIncomplete(t *testing.T) {
payload := map[string]any{
"messages": []map[string]any{{"messageId": "msg-1"}},
"complete": true,
"failedCount": 0,
"failures": []map[string]any{},
}
ledger := map[string]any{
"failedCount": 1,
"failures": []map[string]any{{
"messageId": "msg-1",
"error": "download failed",
}},
}
AttachMessageResourceDownloads(payload, ledger)
if payload["complete"] != false || payload["failedCount"] != 1 {
t.Fatalf("task completeness = %#v", payload)
}
messages, _ := payload["messages"].([]map[string]any)
if len(messages) != 1 || messages[0]["messageId"] != "msg-1" {
t.Fatalf("messages were dropped: %#v", messages)
}
failures, _ := payload["failures"].([]map[string]any)
if len(failures) != 1 || failures[0]["stage"] != "resource-download" {
t.Fatalf("resource failures = %#v", failures)
}
}
func TestCrossPlatformCoverageListPinProjectPreservesThreadIdentity(t *testing.T) {
func TestListPinProjectPreservesThreadIdentity(t *testing.T) {
got := listPinProject(map[string]any{
"result": map[string]any{
"messages": []any{
@@ -94,13 +94,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "search_groups",
wantArgs: map[string]any{"keyword": "树莓派", "limit": 5},
},
{
name: "chat search command alias and positional query",
argv: []string{"chat", "+search-group", "树莓派", "--yes"},
wantProduct: "im",
wantTool: "search_groups",
wantArgs: map[string]any{"keyword": "树莓派", "limit": 20},
},
{
name: "bot find keyword",
argv: []string{"chat", "+bot-find", "--keyword", "日报", "--yes"},
@@ -182,16 +175,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
"openConversationId": "cid-1",
},
},
{
name: "message recall plural single id alias",
argv: []string{"chat", "+messages-recall", "--conversation-id", "cid-1", "--message-ids", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "recall_message",
wantArgs: map[string]any{
"openConversationId": "cid-1",
"openMessageId": "msg-1",
},
},
}
for _, tc := range tests {
@@ -220,15 +203,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
}
}
func TestCrossPlatformCoverageConversationNotificationSubSwitchesPublishMutePrerequisite(t *testing.T) {
for _, command := range []shortcut.Shortcut{ConversationMuteAtAll, ConversationMuteRedEnvelope} {
if !strings.Contains(command.Intent, "+conversation-mute") ||
!strings.Contains(command.Intent, "NotificationOffNotEnabled") {
t.Errorf("%s intent does not publish the live platform prerequisite: %q", command.Command, command.Intent)
}
}
}
func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
t.Run("recognize open DingTalk IDs", func(t *testing.T) {
tests := []struct {
@@ -274,7 +248,7 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
})
}
func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
fake := &muteMemberResolutionCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -303,7 +277,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
}
}
func TestCrossPlatformCoverageConversationCategoryTitleValidation(t *testing.T) {
func TestConversationCategoryTitleValidation(t *testing.T) {
tests := []struct {
name string
argv []string
@@ -7,8 +7,6 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
@@ -93,62 +91,6 @@ func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) {
}
}
func TestCrossPlatformCoverageConversationListFailureBoundaries(t *testing.T) {
for _, tail := range [][]string{
{"--limit", "0"},
{"--limit", "101"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+conversation-list"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid conversation args succeeded: %v", tail)
}
}
for _, tc := range []struct {
name string
caller *larkAlignmentCaller
args []string
wantError bool
}{
{name: "first read failure", caller: &larkAlignmentCaller{failProductTool: "im/list_all_conversations"}, wantError: true},
{name: "later read failure", caller: &larkAlignmentCaller{
sequenceResponses: map[string][]string{"im/list_all_conversations": {`{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}},
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
}, args: []string{"--page-all"}},
{name: "missing pagination", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[]}}`}}},
{name: "invalid cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":"bad"}}`}}},
{name: "stalled cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--cursor", "2"}},
{name: "page limit", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--page-limit", "1"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+conversation-list"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
})
}
for value, want := range map[any]int64{int(1): 1, int64(2): 2, float64(3): 3, "4": 4} {
got, err := conversationPaginationCursor(value)
if err != nil || got != want {
t.Fatalf("cursor %#v = %d, %v; want %d", value, got, err, want)
}
}
if _, err := conversationPaginationCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
if got := unwrapConversationTuple(nil); got != nil {
t.Fatalf("empty tuple = %#v", got)
}
}
func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
tests := []struct {
name string
@@ -156,12 +98,6 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
args []string
wantError string
}{
{
name: "owner query resolution",
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--owner-query", "群主", "--yes"},
wantError: "fixture lower call failed",
},
{
name: "current profile call",
caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"},
@@ -216,18 +152,6 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"},
wantError: "fixture lower call failed",
},
{
name: "chat update resolution",
caller: &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}},
args: []string{"chat", "+chat-update", "--group", "missing", "--name", "新群", "--yes"},
wantError: "没有找到",
},
{
name: "reply write",
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
wantError: "fixture lower call failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -253,27 +177,6 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
dry.calls[1].tool != "create_group_conversation" {
t.Fatalf("chat-create dry-run calls = %#v", dry.calls)
}
external := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"外部联系人","openDingTalkId":"D-external"}]}`,
}}
helpers.InitDeps(external)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageNaturalGroupReadFailures(t *testing.T) {
for _, command := range []string{"+chat-invite-url", "+chat-bots"} {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", command, "--group", "missing"})
if err := root.Execute(); err == nil {
t.Errorf("%s missing natural group unexpectedly resolved", command)
}
}
}
func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) {
@@ -337,45 +240,6 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
caller *larkAlignmentCaller
args []string
}{
{name: "multiple recall ids", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-recall", "--message-ids", "m1,m2", "--yes"}},
{name: "recall lookup failure", caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "recall lookup empty", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "recall lookup missing conversation", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[{"openMessageId":"m1"}]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "card status low", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "0", "--yes"}},
{name: "card status high", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "6", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs(tc.args)
if err := root.Execute(); err == nil {
t.Fatal("failure boundary unexpectedly succeeded")
}
})
}
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
for value, want := range map[any]int{
int(1): 1, int32(2): 2, int64(3): 3, float32(4): 4, float64(5): 5, "bad": 0,
} {
if got := messageLedgerInt(value); got != want {
t.Fatalf("messageLedgerInt(%#v) = %d, want %d", value, got, want)
}
}
}
func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) {
fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`}
helpers.InitDeps(fake)
@@ -524,90 +388,6 @@ func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T)
}
}
func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.T) {
readGroupFile := messagesSendReadGroupFile
t.Cleanup(func() { messagesSendReadGroupFile = readGroupFile })
for _, tail := range [][]string{
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--groups", "c1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", "c1", "--groups-file", "groups.txt", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--chat-query", "群", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", "", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", strings.Join(makeIDs(101), ","), "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "/absolute.txt", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "../escape.txt", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "missing.txt", "--text", "x"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+messages-send"}, append(tail, "--yes")...))
if err := root.Execute(); err == nil {
t.Errorf("invalid unified send args succeeded: %v", tail)
}
}
temp := t.TempDir()
if err := os.Mkdir(filepath.Join(temp, "groups-dir"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(temp, "large.txt"), make([]byte, messagesSendMaxGroupFileSize+1), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(temp, "unreadable.txt"), []byte("cid"), 0o600); err != nil {
t.Fatal(err)
}
messagesSendReadGroupFile = func(path string) ([]byte, error) {
if filepath.Base(path) == "unreadable.txt" {
return nil, os.ErrPermission
}
return readGroupFile(path)
}
t.Chdir(temp)
for _, path := range []string{"groups-dir", "large.txt", "unreadable.txt"} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups-file", path, "--text", "x", "--yes"})
if err := root.Execute(); err == nil {
t.Errorf("invalid group file %q succeeded", path)
}
}
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("batch calls = %#v", fake.calls)
}
for _, call := range fake.calls {
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
call.args["isAtAll"] != "true" {
t.Fatalf("batch mention args = %#v", call.args)
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat", Command: "+coverage-unified-execute-error",
Flags: MessagesSend.Flags, Execute: executeMessagesSend,
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+coverage-unified-execute-error", "--identity", "bot", "--robot-code", "r",
"--groups-file", "missing-again.txt", "--text", "x",
})
if err := root.Execute(); err == nil {
t.Fatal("execute-time group file error was swallowed")
}
}
func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) {
tests := []struct {
name string
-11
View File
@@ -1,11 +0,0 @@
// 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.
//go:build !windows
package chat
import "os"
var replaceFileAtomically = os.Rename
@@ -1,11 +0,0 @@
// 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.
//go:build windows
package chat
import "golang.org/x/sys/windows"
var replaceFileAtomically = windows.Rename
+2 -256
View File
@@ -17,22 +17,13 @@ import (
"strings"
"testing"
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"
)
type chatOutputErrorWriter struct {
err error
}
func (w chatOutputErrorWriter) Write([]byte) (int, error) {
return 0, w.err
}
func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
want := []string{
"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id",
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
"uuid", "idempotency-key",
}
@@ -63,251 +54,6 @@ func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInp
}
}
func TestCrossPlatformCoverageMessagesSendIdentityDescriptorMatchesRuntimeSurface(t *testing.T) {
capabilities := MessageIdentityCapabilities()
if len(capabilities) != 3 {
t.Fatalf("identity capabilities = %#v", capabilities)
}
byIdentity := make(map[string]MessageIdentityCapability, len(capabilities))
for _, capability := range capabilities {
byIdentity[capability.Identity] = capability
}
if !byIdentity["user"].IdempotencyKeys || byIdentity["user"].BatchLedger {
t.Fatalf("user capability = %#v", byIdentity["user"])
}
if !byIdentity["bot"].BatchLedger || byIdentity["bot"].IdempotencyKeys ||
!reflect.DeepEqual(byIdentity["bot"].ContentTypes, []string{"text", "markdown"}) {
t.Fatalf("bot capability = %#v", byIdentity["bot"])
}
if byIdentity["webhook"].BatchLedger || byIdentity["webhook"].IdempotencyKeys {
t.Fatalf("webhook capability = %#v", byIdentity["webhook"])
}
capabilities[0].ContentTypes[0] = "mutated"
if MessageIdentityCapabilities()[0].ContentTypes[0] == "mutated" {
t.Fatal("identity capability descriptor leaked mutable storage")
}
if !messageIdentitySupportsContent("user", "audio") ||
!messageIdentitySupportsContent("user", "video") ||
messageIdentitySupportsContent("missing", "text") {
t.Fatal("identity content normalization or unknown-identity guard drifted")
}
}
func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeBoundaries(t *testing.T) {
card := CurrentCardWorkflowContract()
if card.Version != "im.streaming-card.v1" || card.CallbackSupported ||
!reflect.DeepEqual(card.ContentTypes, []string{"streaming-text"}) || len(card.FlowStatuses) != 5 {
t.Fatalf("card contract = %#v", card)
}
card.Targets[0] = "mutated"
if CurrentCardWorkflowContract().Targets[0] == "mutated" {
t.Fatal("card contract leaked mutable storage")
}
boundaries := CurrentIMCapabilityBoundaries()
byName := make(map[string]bool, len(boundaries))
for _, boundary := range boundaries {
byName[boundary.Capability] = boundary.Supported
if boundary.Alternative == "" {
t.Errorf("boundary %s lacks alternative", boundary.Capability)
}
}
for _, unsupported := range []string{"thread-write", "bot-rich-media", "card-action-callback", "resource-resume"} {
if byName[unsupported] {
t.Errorf("unsupported boundary %s was advertised", unsupported)
}
}
for _, supported := range []string{"group-member-full-pagination", "group-owner-selection"} {
if !byName[supported] {
t.Errorf("supported boundary %s was hidden", supported)
}
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b,cid-a", "--markdown", "通知", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("multi-group calls = %#v", fake.calls)
}
for index, target := range []string{"cid-a", "cid-b"} {
if fake.calls[index].tool != "send_robot_group_message" ||
fake.calls[index].args["openConversationId"] != target {
t.Fatalf("multi-group call[%d] = %#v", index, fake.calls[index])
}
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != "im.batch-write.v1" || payload["ok"] != true ||
payload["requestedCount"] != float64(2) || payload["succeededCount"] != float64(2) ||
payload["failedCount"] != float64(0) {
t.Fatalf("multi-group ledger = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupFailuresReturnNonzero(t *testing.T) {
tests := []struct {
name string
fake *larkAlignmentCaller
wantSucceeded float64
wantFailed float64
wantPartial bool
}{
{
name: "partial failure",
fake: &larkAlignmentCaller{failProductToolAt: map[string]int{
"bot/send_robot_group_message": 2,
}},
wantSucceeded: 1,
wantFailed: 1,
wantPartial: true,
},
{
name: "all failed",
fake: &larkAlignmentCaller{failProductTool: "bot/send_robot_group_message"},
wantSucceeded: 0,
wantFailed: 2,
wantPartial: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
helpers.InitDeps(tt.fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
})
err := root.Execute()
if err == nil {
t.Fatal("failed multi-group delivery returned success")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
typed.Reason != "batch_write_failed" || typed.ExitCode() == 0 {
t.Fatalf("batch error = %#v (%v)", typed, err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["ok"] != false || payload["partial"] != tt.wantPartial ||
payload["succeededCount"] != tt.wantSucceeded ||
payload["failedCount"] != tt.wantFailed {
t.Fatalf("failure ledger = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPropagatesOutputFailure(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
wantErr := errors.New("fixture output failed")
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: wantErr})
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
})
if err := root.Execute(); !errors.Is(err, wantErr) {
t.Fatalf("output error = %v, want %v", err, wantErr)
}
if len(fake.calls) != 2 {
t.Fatalf("multi-group calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendBotGroupsFileUsesSafeDeduplicatedTargets(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("groups.txt", []byte("# comment\ncid-a,cid-b\ncid-a\n"), 0o600); err != nil {
t.Fatal(err)
}
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups-file", "groups.txt", "--text", "通知", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("groups-file calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDeduplicatesMember(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
"--owner-open-dingtalk-id", "D-owner", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_group_conversation" {
t.Fatalf("explicit owner calls = %#v", fake.calls)
}
create := fake.calls[0].args
if create["ownerOpenDingTalkId"] != "D-owner" {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
"--owner-query", "张三", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "create_group_conversation" {
t.Fatalf("owner query calls = %#v", fake.calls)
}
create := fake.calls[1].args
if create["ownerOpenDingTalkId"] != "D-owner" {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
@@ -1,81 +0,0 @@
// 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 chat
// MessageIdentityCapability is the reviewed Runtime capability descriptor for
// +messages-send. Validation and Skill drift checks consume this same typed
// source; it describes only behavior that the current lower transports expose.
type MessageIdentityCapability struct {
Identity string
Targets []string
ContentTypes []string
NaturalTargets []string
MentionTargets []string
IdempotencyKeys bool
BatchLedger bool
}
var messageIdentityCapabilities = []MessageIdentityCapability{
{
Identity: "user",
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
ContentTypes: []string{"text", "markdown", "image-media-id", "file", "audio-as-file", "video-as-file"},
NaturalTargets: []string{"chat-query", "user-query"},
MentionTargets: []string{"open-dingtalk-id", "all"},
IdempotencyKeys: true,
BatchLedger: false,
},
{
Identity: "bot",
Targets: []string{"group", "groups", "direct-users", "direct-open-dingtalk-ids"},
ContentTypes: []string{"text", "markdown"},
NaturalTargets: []string{},
MentionTargets: []string{"user-id", "open-dingtalk-id", "all"},
IdempotencyKeys: false,
BatchLedger: true,
},
{
Identity: "webhook",
Targets: []string{"token-owned-group"},
ContentTypes: []string{"text", "markdown"},
NaturalTargets: []string{},
MentionTargets: []string{"user-id", "mobile", "all"},
IdempotencyKeys: false,
BatchLedger: false,
},
}
// MessageIdentityCapabilities returns defensive copies of the public matrix.
func MessageIdentityCapabilities() []MessageIdentityCapability {
out := make([]MessageIdentityCapability, len(messageIdentityCapabilities))
for i, capability := range messageIdentityCapabilities {
out[i] = capability
out[i].Targets = append([]string(nil), capability.Targets...)
out[i].ContentTypes = append([]string(nil), capability.ContentTypes...)
out[i].NaturalTargets = append([]string(nil), capability.NaturalTargets...)
out[i].MentionTargets = append([]string(nil), capability.MentionTargets...)
}
return out
}
func messageIdentitySupportsContent(identity, contentType string) bool {
if contentType == "audio" || contentType == "video" {
contentType += "-as-file"
} else if contentType == "image" {
contentType = "image-media-id"
}
for _, capability := range messageIdentityCapabilities {
if capability.Identity != identity {
continue
}
for _, supported := range capability.ContentTypes {
if supported == contentType {
return true
}
}
return false
}
return false
}
-76
View File
@@ -1,76 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
// CardFlowStatus is one reviewed state accepted by DingTalk's streaming-card
// update transport.
type CardFlowStatus struct {
Value int
Name string
}
// CardWorkflowContract describes the real card subset exposed by the current
// Runtime. It intentionally does not claim Lark card JSON/component compilation
// or callback consumption, neither of which exists in the lower interface.
type CardWorkflowContract struct {
Version string
Targets []string
ContentTypes []string
FlowStatuses []CardFlowStatus
CallbackSupported bool
}
var currentCardWorkflowContract = CardWorkflowContract{
Version: "im.streaming-card.v1",
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
ContentTypes: []string{"streaming-text"},
FlowStatuses: []CardFlowStatus{
{Value: 1, Name: "processing"},
{Value: 2, Name: "typing"},
{Value: 3, Name: "completed"},
{Value: 4, Name: "executing"},
{Value: 5, Name: "error"},
},
CallbackSupported: false,
}
// CurrentCardWorkflowContract returns a defensive copy for policy and docs.
func CurrentCardWorkflowContract() CardWorkflowContract {
contract := currentCardWorkflowContract
contract.Targets = append([]string(nil), contract.Targets...)
contract.ContentTypes = append([]string(nil), contract.ContentTypes...)
contract.FlowStatuses = append([]CardFlowStatus(nil), contract.FlowStatuses...)
return contract
}
func validCardFlowStatus(value int) bool {
for _, status := range currentCardWorkflowContract.FlowStatuses {
if status.Value == value {
return true
}
}
return false
}
// IMCapabilityBoundary makes unsupported Lark-parity requests explicit and
// testable instead of leaving them to prose or transport guessing.
type IMCapabilityBoundary struct {
Capability string
Supported bool
Alternative string
}
var currentIMCapabilityBoundaries = []IMCapabilityBoundary{
{Capability: "thread-write", Supported: false, Alternative: "quote reply with +messages-reply; thread reading with +thread-replies"},
{Capability: "bot-rich-media", Supported: false, Alternative: "bot text/markdown, or current-user file/image send"},
{Capability: "card-action-callback", Supported: false, Alternative: "streaming text card create/update only"},
{Capability: "resource-resume", Supported: false, Alternative: "atomic whole-file download with explicit retry"},
{Capability: "group-member-full-pagination", Supported: true, Alternative: "+chat-members-list or +group-members"},
{Capability: "group-owner-selection", Supported: true, Alternative: "+chat-create owner flags"},
}
// CurrentIMCapabilityBoundaries returns the reviewed positive/negative matrix.
func CurrentIMCapabilityBoundaries() []IMCapabilityBoundary {
return append([]IMCapabilityBoundary(nil), currentIMCapabilityBoundaries...)
}
+32 -146
View File
@@ -13,81 +13,44 @@ import (
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"
)
// 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.
// ChatCreate creates a DingTalk group as the current user. It intentionally
// does not advertise Lark-only owner, description, initial-bot, or visibility
// semantics.
var ChatCreate = shortcut.Shortcut{
Service: "chat",
Command: "+chat-create",
Product: "im",
Description: "按成员和可选群主全量预检后创建一个钉钉群聊",
Intent: "当你要创建钉钉群聊时使用;成员可传稳定 ID 或 --member-query 姓名,群主默认当前用户,也可用 --owner-open-dingtalk-id 或 --owner-query 明确指定。所有自然身份会在唯一解析并去重后才执行一次创建,任一零命中或多命中都会整体停止。",
Description: "以当前用户身份创建钉钉群聊",
Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表"},
{Name: "member-query", Type: shortcut.FlagStringSlice, Desc: "按姓名/花名唯一解析的初始成员,可逗号分隔或重复传入"},
{Name: "owner-open-dingtalk-id", Type: shortcut.FlagString, Desc: "明确指定群主 openDingTalkId(与 --owner-query 互斥;省略时群主为当前用户)"},
{Name: "owner-query", Type: shortcut.FlagString, Desc: "按姓名唯一解析群主 openDingTalkId(与 --owner-open-dingtalk-id 互斥)"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "INTERNAL", Desc: "群类型", Enum: []string{"INTERNAL", "EXTERNAL", "NORMAL"}},
{Name: "thread", Type: shortcut.FlagBool, Desc: "创建为话题群"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "member-query"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"owner-open-dingtalk-id", "owner-query"}},
},
Tips: []string{
`dws chat +chat-create --name "项目冲刺群" --users userId1,userId2`,
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
`dws chat +chat-create --name "合作群" --users userId1,userId2 --type EXTERNAL`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
resolvedMembers, err := targetresolver.ResolveUsers(
rt,
rt.StrSlice("member-query"),
targetresolver.IdentityAny,
)
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
if err != nil {
return err
return fmt.Errorf("读取当前用户以设置群主失败: %w", err)
}
ownerOpenID := rt.Str("owner-open-dingtalk-id")
if query := rt.Str("owner-query"); query != "" {
resolvedOwner, resolveErr := targetresolver.ResolveUser(
rt, query, targetresolver.IdentityOpenDingTalkID)
if resolveErr != nil {
return resolveErr
}
ownerOpenID = resolvedOwner.Selected.OpenDingTalkID
}
members := make([]string, 0, len(rt.StrSlice("users"))+len(resolvedMembers)+1)
if ownerOpenID != "" {
members = append(members, ownerOpenID)
} else {
profile, profileErr := rt.CallMCPData("contact", "get_current_user_profile", nil)
if profileErr != nil {
return fmt.Errorf("读取当前用户以设置群主失败: %w", profileErr)
}
currentUserID := currentProfileUserID(profile)
if currentUserID == "" {
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
}
members = append(members, currentUserID)
currentUserID := currentProfileUserID(profile)
if currentUserID == "" {
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
}
members := []string{currentUserID}
for _, member := range rt.StrSlice("users") {
member = strings.TrimSpace(member)
if member != "" {
members = appendUniqueShortcutString(members, member)
}
}
for _, resolved := range resolvedMembers {
member := resolved.Selected.UserID
if member == "" {
member = resolved.Selected.OpenDingTalkID
}
members = appendUniqueShortcutString(members, member)
}
params := map[string]any{
"groupName": rt.Str("name"),
"groupMembers": members,
@@ -96,9 +59,6 @@ var ChatCreate = shortcut.Shortcut{
if rt.Bool("thread") {
params["convThreadEnabled"] = true
}
if ownerOpenID != "" {
params["ownerOpenDingTalkId"] = ownerOpenID
}
if rt.DryRun() {
return rt.CallMCP("create_group_conversation", params)
}
@@ -154,23 +114,18 @@ func normalizeCreatedConversation(data map[string]any) {
var ChatUpdate = shortcut.Shortcut{
Service: "chat",
Command: "+chat-update",
Aliases: []string{"+chat-rename"},
Product: "chat",
Description: "更新群名称(仅名称,不支持 description)",
Intent: "当你只需要修改群名称时使用;--group 可传群名或 openConversationId,群名必须唯一解析后才会写入。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
},
Tips: []string{`dws chat +chat-update --group <群名或openConversationId> --name "新群名"`},
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
Execute: func(rt *shortcut.RuntimeContext) error {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), "")
if err != nil {
return err
}
return rt.CallMCP("update_group_name", map[string]any{
"openconversation_id": resolved.Selected.OpenConversationID,
"openconversation_id": rt.Str("group"),
"group_name": rt.Str("name"),
})
},
@@ -183,8 +138,8 @@ var MessagesReply = shortcut.Shortcut{
Service: "chat",
Command: "+messages-reply",
Product: "chat",
Description: "引用回复一条已有消息,并返回可继续查询或撤回的发送上下文",
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -220,79 +175,10 @@ var MessagesReply = shortcut.Shortcut{
if value := rt.StrFirst("idempotency-key", "uuid"); value != "" {
params["uuid"] = value
}
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": "im.message-reply.v1",
"dryRun": true,
"willSend": false,
"transport": "chat/send_personal_message",
"arguments": params,
"conversationId": rt.Str("conversation-id"),
"referencedMessage": map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
},
})
}
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
if err != nil {
return err
}
enrichReplyResult(data, rt, refSender)
return rt.Output(data)
return rt.CallMCP("send_personal_message", params)
},
}
func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) {
data["contractVersion"] = "im.message-reply.v1"
data["conversationId"] = rt.Str("conversation-id")
data["referencedMessage"] = map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
"resolutionSource": func() string {
if rt.Str("ref-sender") != "" {
return "explicit"
}
return "message_lookup"
}(),
}
if key := rt.StrFirst("idempotency-key", "uuid"); key != "" {
data["idempotencyKey"] = key
}
if value := replyResponseValue(data, "openMessageId", "openMsgId", "messageId", "msgId"); value != nil {
data["messageId"] = value
}
if value := replyResponseValue(data, "openConvThreadId", "threadId", "topicId"); value != nil {
data["threadId"] = value
}
if value := replyResponseValue(data, "deliveryStatus", "sendStatus", "status"); value != nil {
data["deliveryStatus"] = value
data["deliveryStatusKnown"] = true
} else {
data["deliveryStatus"] = "unknown"
data["deliveryStatusKnown"] = false
}
}
func replyResponseValue(data map[string]any, keys ...string) any {
scopes := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if nested, ok := data[wrapper].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range keys {
if value, ok := scope[key]; ok && value != nil {
if text, isString := value.(string); !isString || strings.TrimSpace(text) != "" {
return value
}
}
}
}
return nil
}
func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
if value := rt.Str("ref-sender"); value != "" {
if isOpenID(value) {
@@ -417,8 +303,8 @@ var FlagCreate = shortcut.Shortcut{
Intent: "当你要把同一会话中的一条或多条消息加入当前用户的个人收藏时使用;逐项返回成功/失败 ledger。这是消息 favorite,不是消息 Pin、会话置顶或 feed-layer thread flag。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
},
Constraints: []shortcut.Constraint{
@@ -444,8 +330,8 @@ var FlagCancel = shortcut.Shortcut{
Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
},
Constraints: []shortcut.Constraint{
@@ -503,8 +389,8 @@ var FlagList = shortcut.Shortcut{
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 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: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
@@ -630,10 +516,10 @@ var ChatList = shortcut.Shortcut{
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型只能包含 group 和/或 p2p;省略时默认只返回群聊"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1-100 之间"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型:group、p2p;省略时默认只返回群聊"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量(1-100)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标(钉钉为整数游标的字符串形式)"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
},
@@ -835,7 +721,7 @@ func chatListFilterTypes(chats []map[string]any, types []string) []map[string]an
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
ChatCreate,
ChatList,
ChatUpdate,
@@ -844,5 +730,5 @@ func init() {
FlagCancel,
FlagList,
FeedGroupQueryItem,
)...)
)
}
+22 -228
View File
@@ -23,14 +23,11 @@ type larkAlignmentCall struct {
}
type larkAlignmentCaller struct {
calls []larkAlignmentCall
failTarget string
failProductTool string
failProductToolAt map[string]int
callCounts map[string]int
category string
responses map[string]string
sequenceResponses map[string][]string
calls []larkAlignmentCall
failTarget string
failProductTool string
category string
responses map[string]string
}
func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -39,16 +36,9 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
return nil, errors.New("fixture write failed")
}
key := product + "/" + tool
if f.callCounts == nil {
f.callCounts = map[string]int{}
}
f.callCounts[key]++
if f.failProductTool == key {
return nil, errors.New("fixture lower call failed")
}
if f.failProductToolAt[key] == f.callCounts[key] {
return nil, errors.New("fixture sequenced lower call failed")
}
text := `{"success":true}`
switch key {
case "contact/get_current_user_profile":
@@ -85,10 +75,6 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
if response, ok := f.responses[key]; ok {
text = response
}
if responses := f.sequenceResponses[key]; len(responses) > 0 {
text = responses[0]
f.sequenceResponses[key] = responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -101,67 +87,7 @@ func (f *larkAlignmentCaller) DryRun() bool { return false }
func (f *larkAlignmentCaller) Fields() string { return "" }
func (f *larkAlignmentCaller) JQ() string { return "" }
func TestCrossPlatformCoverageEvaluationRegressionNaturalGroupTargetsAndRecallInference(t *testing.T) {
t.Run("group name to bots", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
"bot/list_group_bots": `{"result":{"bots":[]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-bots", "--group", "项目群"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "list_group_bots" || fake.calls[1].args["openConversationId"] != "cid-project" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("group query to invite url", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "项目群"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "get_group_invite_url" || fake.calls[1].args["openConversationId"] != "cid-project" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("stable id in group query bypasses search", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "cid-fixture-chat-0001"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "get_group_invite_url" ||
fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("message id fills conversation before recall", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-recall", "--message-ids", "msg", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_messages_by_ids" || fake.calls[1].tool != "recall_message" || fake.calls[1].args["openConversationId"] != "cid" {
t.Fatalf("calls = %#v", fake.calls)
}
})
}
func TestCrossPlatformCoverageChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -201,80 +127,7 @@ func TestCrossPlatformCoverageChatCreateAddsCurrentUserAndNormalizesResult(t *te
}
}
func TestCrossPlatformCoverageChatCreateResolvesEveryNaturalMemberBeforeCreating(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user","openDingTalkId":"D-resolved"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--users", "explicit-user",
"--member-query", "张三",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 {
t.Fatalf("calls = %#v, want member resolve + current profile + create", fake.calls)
}
if fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "get_current_user_profile" ||
fake.calls[2].tool != "create_group_conversation" {
t.Fatalf("call order = %#v", fake.calls)
}
if got, want := fake.calls[2].args["groupMembers"], []string{"self-user", "explicit-user", "resolved-user"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageChatCreateNaturalMemberAmbiguityStopsBeforeProfileAndCreate(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1"},{"name":"张三","userId":"u2"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--member-query", "张三",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("ambiguous member unexpectedly created a group")
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("ambiguous member reached profile/create: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatCreateNaturalMemberDryRunUsesSameResolutionChain(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--member-query", "张三",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "get_current_user_profile" ||
fake.calls[2].tool != "create_group_conversation" {
t.Fatalf("dry-run calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
tests := []struct {
name string
args []string
@@ -348,7 +201,7 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}
}
func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -369,7 +222,7 @@ func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t
}
}
func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
func TestLarkAlignmentWriteMappings(t *testing.T) {
tests := []struct {
name string
args []string
@@ -379,11 +232,11 @@ func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
}{
{
name: "chat-update-name-only",
args: []string{"chat", "+chat-update", "--group", "cid-fixture-chat-0001", "--name", "新群名", "--yes"},
args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"},
product: "chat",
tool: "update_group_name",
wantArgs: map[string]any{
"openconversation_id": "cid-fixture-chat-0001",
"openconversation_id": "cid",
"group_name": "新群名",
},
},
@@ -438,32 +291,10 @@ func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
}
}
func TestCrossPlatformCoverageObservedChatRenameAliasResolvesNameBeforeWrite(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目评测群"}],"hasMore":false}`,
}}
func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-rename", "--group", "项目评测群", "--name", "项目讨论群", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "search_groups" || fake.calls[1].tool != "update_group_name" {
t.Fatalf("calls = %#v", fake.calls)
}
if fake.calls[1].args["openconversation_id"] != "cid-project" || fake.calls[1].args["group_name"] != "项目讨论群" {
t.Fatalf("write args = %#v", fake.calls[1].args)
}
}
func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openMessageId":"new-msg","openConvThreadId":"thread-1","sendStatus":"accepted"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-reply",
"--conversation-id", "cid",
@@ -496,46 +327,9 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
content["content"] != "收到" {
t.Fatalf("reply content = %#v", content)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != "im.message-reply.v1" ||
payload["messageId"] != "new-msg" ||
payload["conversationId"] != "cid" ||
payload["threadId"] != "thread-1" ||
payload["deliveryStatus"] != "accepted" ||
payload["idempotencyKey"] != "reply-uuid" {
t.Fatalf("reply result context = %#v", payload)
}
referenced, _ := payload["referencedMessage"].(map[string]any)
if referenced["messageId"] != "msg" || referenced["resolutionSource"] != "explicit" {
t.Fatalf("referenced message context = %#v", referenced)
}
}
func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--text", "收到",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("reply dry-run reached write transport: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
fake := &larkAlignmentCaller{failTarget: "m2"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -547,8 +341,8 @@ func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *tes
"--conversation-id", "cid",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("partial batch failure returned success")
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v", fake.calls)
@@ -565,7 +359,7 @@ func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *tes
}
}
func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -596,7 +390,7 @@ func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWitho
}
}
func TestCrossPlatformCoverageMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -656,7 +450,7 @@ func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *test
}
}
func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -685,7 +479,7 @@ func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *
}
}
func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
message := map[string]any{
"content": map[string]any{
"mentions": []any{
@@ -833,7 +627,7 @@ func TestChatListP2POnlyDropsGroups(t *testing.T) {
}
}
func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
conversations := []map[string]any{
{"openConversationId": "cid-a", "conversationName": "A"},
{"openConversationId": "cid-b", "conversationName": "B"},
@@ -851,7 +645,7 @@ func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissi
}
}
func TestCrossPlatformCoverageFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
fake := &larkAlignmentCaller{
category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`,
}
-120
View File
@@ -1,120 +0,0 @@
// 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 chat
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ValidateMessageExportOutput applies the same workspace-relative and
// no-symlink boundary used by message resource downloads. The export target is
// always a file; directory-shaped paths are rejected instead of inventing a
// name.
func ValidateMessageExportOutput(output string) error {
trimmed := strings.TrimSpace(output)
if strings.HasSuffix(trimmed, "/") ||
strings.HasSuffix(trimmed, string(os.PathSeparator)) {
return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录")
}
if !strings.EqualFold(filepath.Ext(trimmed), ".json") {
return apperrors.NewValidation("--output 必须使用 .json 文件扩展名")
}
return validateResourceDownloadOutputFlag(output, "--output")
}
// WriteMessageExportJSON atomically publishes the exact structured message
// ledger. It defaults to no-clobber and never follows a symlink outside the
// current working directory.
func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) {
if err := ValidateMessageExportOutput(output); err != nil {
return "", 0, err
}
cwd, err := resourceGetwd()
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
base, err := resourceAbs(cwd)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realBase, err := resourceEvalSymlinks(base)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output)))
parent := filepath.Dir(target)
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
return "", 0, err
}
realParent, err := resourceEvalSymlinks(parent)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
}
parentRel, err := resourceRel(realBase, realParent)
if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录")
}
target = filepath.Join(realParent, filepath.Base(target))
if info, statErr := resourceLstat(target); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", 0, apperrors.NewValidation("--output 目标不能是符号链接")
}
if info.IsDir() {
return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件")
}
if !overwrite {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
}
rendered, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err))
}
rendered = append(rendered, '\n')
temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err))
}
tempPath := temp.Name()
defer func() {
_ = resourceTempClose(temp)
_ = os.Remove(tempPath)
}()
if _, err := temp.Write(rendered); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err))
}
if err := resourceTempSync(temp); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("同步消息导出失败: %v", err))
}
if err := resourceTempClose(temp); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("关闭消息导出失败: %v", err))
}
if overwrite {
if err := resourceRename(tempPath, target); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
} else if err := resourceLink(tempPath, target); err != nil {
if errors.Is(err, os.ErrExist) {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
relativePath, err = resourceRel(realBase, target)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
}
return filepath.ToSlash(relativePath), len(rendered), nil
}
+19 -31
View File
@@ -22,7 +22,6 @@ import (
"net/http"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"time"
@@ -45,7 +44,7 @@ var (
resourceCopy = io.Copy
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = replaceFileAtomically
resourceRename = os.Rename
resourceLink = os.Link
resourceDownload = downloadResourceAtomically
)
@@ -59,15 +58,15 @@ var MessagesResourceDownload = shortcut.Shortcut{
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。",
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}},
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对路径;不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
{
@@ -205,35 +204,24 @@ func validateResourceDownloadOutputFlag(output, flagName string) error {
if output == "" {
return apperrors.NewValidation(flagName + " 不能为空")
}
if resourcePathIsAbsolute(output) {
// Reject OS-absolute paths and Unix-rooted forms ("/x") even on Windows,
// where filepath.IsAbs("/x") is false but the path is still not a cwd-relative output.
if filepath.IsAbs(output) || strings.HasPrefix(filepath.ToSlash(output), "/") {
return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径")
}
if resourcePathEscapesBase(output) {
clean := filepath.Clean(filepath.FromSlash(output))
if resourcePathEscapesBase(clean) {
return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录")
}
return nil
}
func resourcePathIsAbsolute(value string) bool {
if filepath.IsAbs(value) {
return true
}
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if pathpkg.IsAbs(portable) {
return true
}
return len(portable) >= 2 &&
((portable[0] >= 'a' && portable[0] <= 'z') ||
(portable[0] >= 'A' && portable[0] <= 'Z')) &&
portable[1] == ':'
}
// resourcePathEscapesBase reports whether a relative path escapes its base.
// Normalize both separators so the check remains portable on every host OS.
func resourcePathEscapesBase(value string) bool {
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
clean := pathpkg.Clean(portable)
return clean == ".." || strings.HasPrefix(clean, "../")
// resourcePathEscapesBase reports whether rel escapes its base directory.
// Rel values may use '/' or the OS separator (for example mocked filepath.Rel
// results), so normalize before comparing against "..".
func resourcePathEscapesBase(rel string) bool {
rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel)))
return rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) {
@@ -612,5 +600,5 @@ func downloadResourceAtomically(
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...)
shortcut.Register(MessagesResourceDownload)
}
@@ -599,221 +599,3 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
t.Fatalf("copied = %q", copied.String())
}
}
func TestCrossPlatformCoverageMessageExportFailureBoundaries(t *testing.T) {
for _, output := range []string{"exports/", "exports/messages.txt"} {
if err := ValidateMessageExportOutput(output); err == nil {
t.Fatalf("ValidateMessageExportOutput(%q) succeeded", output)
}
}
if _, _, err := WriteMessageExportJSON("not-json.txt", false, map[string]any{}); err == nil {
t.Fatal("invalid export path reached write pipeline")
}
baseSetup := func(t *testing.T) string {
t.Helper()
resetResourceDownloadHooks(t)
base := t.TempDir()
resourceGetwd = func() (string, error) { return base, nil }
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceCreateTemp = os.CreateTemp
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = os.Rename
resourceLink = os.Link
return base
}
t.Run("path setup", func(t *testing.T) {
baseSetup(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("getwd failure ignored")
}
})
t.Run("absolute path", func(t *testing.T) {
baseSetup(t)
resourceAbs = func(string) (string, error) { return "", errors.New("abs") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("abs failure ignored")
}
})
t.Run("base symlink", func(t *testing.T) {
baseSetup(t)
resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("base symlink failure ignored")
}
})
t.Run("parent symlink", func(t *testing.T) {
base := baseSetup(t)
calls := 0
resourceEvalSymlinks = func(path string) (string, error) {
calls++
if calls == 1 {
return base, nil
}
return "", errors.New("parent")
}
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent symlink failure ignored")
}
})
t.Run("parent creation", func(t *testing.T) {
baseSetup(t)
resourceLstat = func(string) (os.FileInfo, error) {
return os.Stat(filepath.Join(t.TempDir(), "missing-parent"))
}
resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") }
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent creation failure ignored")
}
})
t.Run("parent escapes", func(t *testing.T) {
base := baseSetup(t)
resourceEvalSymlinks = func(path string) (string, error) {
if path == base {
return base, nil
}
return filepath.Dir(base), nil
}
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent escape accepted")
}
})
for _, tc := range []struct {
name string
setup func(t *testing.T, base string)
}{
{name: "existing symlink", setup: func(t *testing.T, base string) {
target := filepath.Join(base, "out.json")
if err := os.Symlink("missing", target); err != nil {
t.Fatal(err)
}
}},
{name: "existing directory", setup: func(t *testing.T, base string) {
if err := os.Mkdir(filepath.Join(base, "out.json"), 0o755); err != nil {
t.Fatal(err)
}
}},
{name: "existing no clobber", setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
}},
{name: "lstat error", setup: func(t *testing.T, _ string) {
resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") }
}},
} {
t.Run(tc.name, func(t *testing.T) {
base := baseSetup(t)
tc.setup(t, base)
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("invalid existing target accepted")
}
})
}
for _, tc := range []struct {
name string
overwrite bool
payload any
setup func(t *testing.T, base string)
}{
{name: "marshal", payload: make(chan int)},
{name: "create temp", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
}},
{name: "write", payload: map[string]any{}, setup: func(t *testing.T, base string) {
resourceCreateTemp = func(string, string) (*os.File, error) {
file, err := os.CreateTemp(base, "closed-*")
if err == nil {
_ = file.Close()
}
return file, err
}
}},
{name: "sync", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceTempSync = func(*os.File) error { return errors.New("sync") }
}},
{name: "close", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceTempClose = func(file *os.File) error {
_ = file.Close()
return errors.New("close")
}
}},
{name: "rename", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
resourceRename = func(string, string) error { return errors.New("rename") }
}},
{name: "link exists", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceLink = func(string, string) error { return os.ErrExist }
}},
{name: "link", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceLink = func(string, string) error { return errors.New("link") }
}},
{name: "final rel", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
calls := 0
resourceRel = func(from, to string) (string, error) {
calls++
if calls < 3 {
return filepath.Rel(from, to)
}
return "", errors.New("rel")
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
base := baseSetup(t)
if tc.setup != nil {
tc.setup(t, base)
}
if _, _, err := WriteMessageExportJSON("out.json", tc.overwrite, tc.payload); err == nil {
t.Fatal("failure hook was ignored")
}
})
}
}
func TestCrossPlatformCoverageMessageExportOverwriteReplacesExistingFile(t *testing.T) {
resetResourceDownloadHooks(t)
base := t.TempDir()
resourceGetwd = func() (string, error) { return base, nil }
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceCreateTemp = os.CreateTemp
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = replaceFileAtomically
resourceLink = os.Link
target := filepath.Join(base, "out.json")
if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil {
t.Fatal(err)
}
relative, size, err := WriteMessageExportJSON(
"out.json", true, map[string]any{"value": "new"},
)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
const want = "{\n \"value\": \"new\"\n}\n"
if relative != "out.json" || size != len(want) || string(data) != want {
t.Fatalf("relative=%q size=%d data=%q", relative, size, data)
}
}
@@ -95,7 +95,7 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) {
func TestResolveResourceDownloadPath(t *testing.T) {
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "downloads"), 0o755); err != nil {
t.Fatal(err)
@@ -122,30 +122,14 @@ func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) {
func TestValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) {
err := validateResourceDownloadOutputFlag("../escape", "--output-dir")
if err == nil || !strings.Contains(err.Error(), "--output-dir") {
t.Fatalf("error = %v, want --output-dir", err)
}
}
func TestCrossPlatformCoverageValidateResourceDownloadOutputRejectsPortableAbsolutePaths(t *testing.T) {
for _, output := range []string{
"/absolute", `\\absolute`, `C:\\absolute`, "C:/absolute",
"C:relative", "c:relative",
} {
if err := validateResourceDownloadOutput(output); err == nil {
t.Errorf("portable absolute output %q unexpectedly accepted", output)
}
}
for _, output := range []string{"../escape", `..\\escape`} {
if err := validateResourceDownloadOutput(output); err == nil {
t.Errorf("portable parent escape %q unexpectedly accepted", output)
}
}
}
func TestCrossPlatformCoverageResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) {
func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) {
base := t.TempDir()
outside := t.TempDir()
link := filepath.Join(base, "linked")
-160
View File
@@ -1,160 +0,0 @@
// 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 chat
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const reviewedChatShortcutInterfaceReason = "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."
// reviewedChatShortcutContractCommands is the exact reviewed migration ledger
// for Chat shortcuts that were delivered by the former committed Schema
// catalog but did not yet carry #830's typed Contract declaration. Exact names
// deliberately prevent a future shortcut from entering Schema by accident.
var reviewedChatShortcutContractCommands = map[string]struct{}{
"+category-add-conversation": {},
"+category-list-conversations": {},
"+category-remove-conversation": {},
"+chat-add-bot": {},
"+chat-audit-join": {},
"+chat-create": {},
"+chat-get-by-id": {},
"+chat-list": {},
"+chat-members-get": {},
"+chat-mute-member": {},
"+chat-quit": {},
"+chat-remove-bot": {},
"+chat-role-remove": {},
"+chat-role-remove-user": {},
"+chat-transfer-owner": {},
"+chat-update": {},
"+chat-update-icon": {},
"+chat-update-settings": {},
"+conversation-clear-messages": {},
"+conversation-clear-red-point": {},
"+conversation-hide": {},
"+conversation-mark-read": {},
"+conversation-mark-unread": {},
"+conversation-mute": {},
"+conversation-set-top": {},
"+feed-group-query-item": {},
"+flag-cancel": {},
"+flag-create": {},
"+flag-list": {},
"+messages-add-emoji": {},
"+messages-add-text-emotion": {},
"+messages-batch-recall-by-bot": {},
"+messages-batch-send-by-bot": {},
"+messages-combine-forward": {},
"+messages-create-text-emotion": {},
"+messages-forward": {},
"+messages-forward-topic": {},
"+messages-list": {},
"+messages-recall": {},
"+messages-recall-by-bot": {},
"+messages-remove-emoji": {},
"+messages-remove-text-emotion": {},
"+messages-reply": {},
"+messages-resource-download": {},
"+messages-resource-url": {},
"+messages-send-by-bot": {},
"+messages-set-pin": {},
"+messages-set-top": {},
"+messages-unset-pin": {},
"+messages-unset-top": {},
}
// withReviewedChatShortcutContracts ports the previously reviewed Chat Schema
// records into #830's typed declaration model. Existing explicit Contracts are
// preserved. Missing Contracts must be listed in the exact ledger above.
func withReviewedChatShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut {
out := make([]shortcut.Shortcut, len(values))
for i, value := range values {
out[i] = value
if !value.Contract.Empty() {
continue
}
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; !reviewed {
continue
}
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
out[i].Contract = reviewedChatShortcutContract(value)
}
return out
}
func reviewedChatShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
switch risk {
case shortcut.RiskWrite:
return contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
case shortcut.RiskHighWrite:
return contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
default:
return contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
}
func reviewedChatShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_")
cliPath := value.Service + " " + value.Command
aliases := make([]string, 0, len(value.Aliases))
for _, alias := range value.Aliases {
aliases = append(aliases, value.Service+" "+alias)
}
description := strings.TrimSpace(value.Intent)
if description == "" {
description = strings.TrimSpace(value.Description)
}
examples := append([]string(nil), value.Tips...)
if len(examples) > 2 {
examples = examples[:2]
}
return corecmd.ContractDecl{
Title: value.Description,
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: reviewedChatShortcutInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: value.Description,
UseWhen: []string{description},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: value.Service,
Name: name,
CanonicalPath: value.Service + "." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
Aliases: aliases,
},
}
}
@@ -1,77 +0,0 @@
// 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 chat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageReviewedChatShortcutContracts(t *testing.T) {
base := shortcut.Shortcut{
Service: "chat",
Command: "+chat-create",
Aliases: []string{"+chat-create-alias"},
Description: "创建群聊",
Intent: "需要创建群聊时使用",
Risk: shortcut.RiskWrite,
Tips: []string{
"dws chat +chat-create --title demo",
"dws chat +chat-create --title demo2",
"dws chat +chat-create --title ignored",
},
}
got := withReviewedChatShortcutContracts(base)
if len(got) != 1 || got[0].Contract.Empty() {
t.Fatalf("reviewed contract = %#v", got)
}
declared := got[0]
if declared.Safety.Effect != "write" || declared.Safety.Confirmation != "user_required" {
t.Fatalf("write safety = %#v", declared.Safety)
}
if declared.Contract.Identity.CanonicalPath != "chat.shortcut_chat_create" ||
declared.Contract.Identity.PrimaryCLIPath != "chat +chat-create" ||
len(declared.Contract.Identity.Aliases) != 1 ||
declared.Contract.Identity.Aliases[0] != "chat +chat-create-alias" {
t.Fatalf("identity = %#v", declared.Contract.Identity)
}
if len(declared.Contract.Selection.Examples) != 2 {
t.Fatalf("examples = %#v, want reviewed maximum of two", declared.Contract.Selection.Examples)
}
high := base
high.Risk = shortcut.RiskHighWrite
if safety := withReviewedChatShortcutContracts(high)[0].Safety; safety.Effect != "destructive" || safety.Risk != "high" {
t.Fatalf("high-write safety = %#v", safety)
}
read := base
read.Risk = shortcut.RiskRead
read.Intent = ""
if declared := withReviewedChatShortcutContracts(read)[0]; declared.Safety.Effect != "read" || declared.Contract.Description != read.Description {
t.Fatalf("read/fallback declaration = %#v", declared)
}
unreviewed := base
unreviewed.Command = "+future-unreviewed"
if declared := withReviewedChatShortcutContracts(unreviewed)[0]; !declared.Contract.Empty() {
t.Fatalf("unreviewed future shortcut entered Schema: %#v", declared.Contract)
}
explicit := base
explicit.Contract = corecmd.ContractDecl{Description: "preserve"}
if declared := withReviewedChatShortcutContracts(explicit)[0]; declared.Contract.Description != "preserve" {
t.Fatalf("explicit contract overwritten: %#v", declared.Contract)
}
}
+45 -194
View File
@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
@@ -19,18 +18,10 @@ import (
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/internal/shortcut/targetresolver"
)
const messagesSendFileUploadTimeout = 10 * time.Minute
const (
messagesSendMaxBotGroups = 100
messagesSendMaxGroupFileSize = 1 << 20
)
var messagesSendReadGroupFile = os.ReadFile
// MessagesSend is the identity-aware common sending entry point. The current
// user branch reuses the native message leaf's reviewed file-upload flow and
// existing-mediaId image path. Bot and webhook remain text/Markdown-only
@@ -39,8 +30,8 @@ var MessagesSend = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Product: "chat",
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
Intent: "当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。",
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -54,15 +45,15 @@ var MessagesSend = shortcut.Shortcut{
CLIPath: "chat +messages-send",
PrimaryCLIPath: "chat +messages-send",
},
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
UseWhen: []string{"当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。"},
AgentSummary: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
UseWhen: []string{"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"},
AvoidWhen: []string{"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"},
Examples: []string{
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
@@ -75,11 +66,7 @@ var MessagesSend = shortcut.Shortcut{
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群 openConversationId(仅 bot;受发送身份能力矩阵约束,逐群返回 typed ledger,最多 100 个)"},
{Name: "groups-file", Type: shortcut.FlagString, Desc: "工作目录内相对文本文件(仅 bot;受发送身份能力矩阵约束),每行或逗号分隔一个群 openConversationId"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一群聊(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"},
@@ -112,14 +99,14 @@ var MessagesSend = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标,多群最多 100 个并逐项返回 ledger;webhook 必须指定 webhook-token;幂等键仅 user 支持",
Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
},
},
Tips: []string{
`dws chat +messages-send --as user --chat-id <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
`dws chat +messages-send --as bot --robot-code <robotCode> --groups <openConversationId1>,<openConversationId2> --text "请提交周报"`,
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
},
Validate: validateMessagesSend,
Execute: executeMessagesSend,
@@ -128,13 +115,7 @@ var MessagesSend = shortcut.Shortcut{
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
group := rt.StrFirst("chat-id", "group")
botGroups, botGroupsErr := messagesSendBotGroups(rt)
if botGroupsErr != nil {
return botGroupsErr
}
chatQuery := rt.Str("chat-query")
userID := rt.Str("user")
userQuery := rt.Str("user-query")
openID := rt.Str("open-dingtalk-id")
users := uniqueShortcutStrings(rt.StrSlice("users"))
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
@@ -153,36 +134,29 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
}
switch identity {
case "user":
targetCount := nonEmptyStringCount(group, chatQuery, userID, userQuery, openID)
targetCount := nonEmptyStringCount(group, userID, openID)
if targetCount != 1 {
return apperrors.NewValidation("--identity user 时 --group/--chat-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个")
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
}
if len(users) > 0 || len(openIDs) > 0 || len(botGroups) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
}
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
}
if (userID != "" || userQuery != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
}
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
case "bot":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
}
if rt.Str("robot-code") == "" {
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
}
hasDirect := len(users)+len(openIDs) > 0
hasGroup := group != "" || len(botGroups) > 0
if hasGroup == hasDirect {
return apperrors.NewValidation("--identity bot 时单群/多群与批量单聊目标必须且只能指定一类")
}
if group != "" && len(botGroups) > 0 {
return apperrors.NewValidation("--identity bot 时 --group/--chat-id 与 --groups/--groups-file 不能同时使用")
if (group != "") == hasDirect {
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
}
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
@@ -196,17 +170,14 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if !messageIdentitySupportsContent(identity, contentType) {
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
}
case "webhook":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受 --chat-query 或 --user-query")
}
if rt.Str("webhook-token") == "" {
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
}
if group != "" || len(botGroups) > 0 || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
}
if len(atOpenIDs) > 0 {
@@ -215,7 +186,7 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if !messageIdentitySupportsContent(identity, contentType) {
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
}
}
@@ -254,10 +225,33 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
if contentType == "file" || contentType == "audio" || contentType == "video" {
return executeMessagesSendUserFile(rt, group, openID, contentType)
}
params := resolvedUserMarkdownParams(rt, ResolvedUserMessageTarget{
GroupID: group,
OpenDingTalkID: openID,
}, title, body, uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), rt.Bool("at-all"), messagesSendIdempotencyKey(rt))
if group != "" {
body = helpers.NormalizeMessageMentions(
body,
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")),
rt.Bool("at-all"),
true,
)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
})
if group != "" {
params["openConversationId"] = group
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
params["atOpenDingTalkIds"] = values
}
if rt.Bool("at-all") {
params["atAll"] = true
}
} else {
params["receiverOpenDingTalkId"] = openID
}
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
case "bot":
body = helpers.NormalizeMessageMentions(
@@ -287,31 +281,6 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
return executeUnifiedMessageWrite(rt, "bot", "send_robot_group_message", params)
}
groups, err := messagesSendBotGroups(rt)
if err != nil {
return err
}
if len(groups) > 0 {
items := make([]shortcutBatchWrite, 0, len(groups))
for _, group := range groups {
arguments := make(map[string]any, len(params)+1)
for key, value := range params {
arguments[key] = value
}
arguments["openConversationId"] = group
if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 {
arguments["atUserIds"] = values
}
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
arguments["atOpendingtalkIds"] = values
}
if rt.Bool("at-all") {
arguments["isAtAll"] = "true"
}
items = append(items, shortcutBatchWrite{target: group, arguments: arguments})
}
return executeShortcutBatchWrite(rt, "bot", "send_robot_group_message", items)
}
if values := uniqueShortcutStrings(rt.StrSlice("users")); len(values) > 0 {
params["userIds"] = values
}
@@ -424,20 +393,6 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string,
if openID != "" || group != "" {
return group, openID, nil
}
if query := rt.Str("chat-query"); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(rt, query)
if resolveErr != nil {
return "", "", resolveErr
}
return resolved.Selected.OpenConversationID, "", nil
}
if query := rt.Str("user-query"); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID)
if resolveErr != nil {
return "", "", resolveErr
}
return "", resolved.Selected.OpenDingTalkID, nil
}
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
if err != nil {
return "", "", err
@@ -445,64 +400,6 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string,
return "", openID, nil
}
// ResolvedUserMessageTarget is the stable target accepted by the shared user
// send engine after natural-name resolution has completed.
type ResolvedUserMessageTarget struct {
GroupID string
OpenDingTalkID string
}
// ExecuteResolvedUserMarkdown lets narrow semantic shortcuts such as +dm and
// +send-to-group reuse the same target/content/AI-tag parameter builder while
// preserving their existing raw lower-response output contract.
func ExecuteResolvedUserMarkdown(
rt *shortcut.RuntimeContext,
target ResolvedUserMessageTarget,
text string,
) error {
params := resolvedUserMarkdownParams(
rt,
target,
text,
text,
nil,
false,
"",
)
return rt.CallMCP("send_personal_message", params)
}
func resolvedUserMarkdownParams(
rt *shortcut.RuntimeContext,
target ResolvedUserMessageTarget,
title, body string,
atOpenIDs []string,
atAll bool,
idempotencyKey string,
) map[string]any {
if target.GroupID != "" {
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
})
addMessagesSendUserTarget(params, target.GroupID, target.OpenDingTalkID)
if target.GroupID != "" {
if len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
}
if atAll {
params["atAll"] = true
}
}
if idempotencyKey != "" {
params["uuid"] = idempotencyKey
}
return params
}
func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) {
userID = strings.TrimSpace(userID)
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
@@ -649,52 +546,6 @@ func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("idempotency-key", "uuid")
}
func messagesSendBotGroups(rt *shortcut.RuntimeContext) ([]string, error) {
if rt.Changed("groups") && rt.Changed("groups-file") {
return nil, apperrors.NewValidation("--groups 与 --groups-file 不能同时指定")
}
groups := uniqueShortcutStrings(rt.StrSlice("groups"))
if path := rt.Str("groups-file"); path != "" {
safePath, err := apperrors.SafeInputPath(path)
if err != nil {
return nil, fmt.Errorf("校验 --groups-file 失败: %w", err)
}
info, err := os.Stat(safePath)
if err != nil {
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
}
if !info.Mode().IsRegular() {
return nil, apperrors.NewValidation("--groups-file 必须是普通文本文件")
}
if info.Size() > messagesSendMaxGroupFileSize {
return nil, apperrors.NewValidation("--groups-file 不能超过 1 MiB")
}
raw, err := messagesSendReadGroupFile(safePath)
if err != nil {
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
}
values := make([]string, 0)
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
values = append(values, strings.Split(line, ",")...)
}
groups = uniqueShortcutStrings(values)
}
if len(groups) > messagesSendMaxBotGroups {
return nil, apperrors.NewValidation(fmt.Sprintf(
"bot 多群发送最多支持 %d 个群,当前 %d 个",
messagesSendMaxBotGroups, len(groups),
))
}
if (rt.Changed("groups") || rt.Changed("groups-file")) && len(groups) == 0 {
return nil, apperrors.NewValidation("bot 多群发送至少需要一个 openConversationId")
}
return groups, nil
}
func shortcutMessageTitle(text string) string {
text = strings.TrimSpace(strings.SplitN(text, "\n", 2)[0])
if utf8.RuneCountInString(text) <= 40 {
@@ -705,5 +556,5 @@ func shortcutMessageTitle(text string) string {
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(MessagesSend)...)
shortcut.Register(MessagesSend)
}
@@ -1,177 +0,0 @@
// 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 chat
import (
stderrors "errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageMessagesSendResolvesNaturalUserAndChatTargets(t *testing.T) {
tests := []struct {
name string
args []string
responses map[string]string
wantTarget string
wantValue string
}{
{
name: "user query",
args: []string{
"chat", "+messages-send", "--as", "user",
"--user-query", "张三", "--text", "你好", "--yes",
},
responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`,
},
wantTarget: "receiverOpenDingTalkId",
wantValue: "D1",
},
{
name: "chat query exact wins",
args: []string{
"chat", "+messages-send", "--as", "user",
"--chat-query", "项目群", "--text", "你好", "--yes",
},
responses: map[string]string{
"im/search_groups": `{"result":[{"title":"项目群-归档","openConversationId":"c2"},{"title":"项目群","openConversationId":"c1"}]}`,
},
wantTarget: "openConversationId",
wantValue: "c1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{responses: tt.responses}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(tt.args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v, want resolve + send", fake.calls)
}
send := fake.calls[1]
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("send = %#v", send)
}
if send.args[tt.wantTarget] != tt.wantValue {
t.Fatalf("%s = %#v, want %q", tt.wantTarget, send.args[tt.wantTarget], tt.wantValue)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendNaturalTargetAmbiguityHasNoWrite(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user",
"--user-query", "张三", "--text", "你好", "--yes",
})
err := root.Execute()
if err == nil {
t.Fatal("ambiguous user unexpectedly sent")
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("ambiguous resolution reached write: %#v", fake.calls)
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("error type = %T", err)
}
if typed.Reason != "resolution_ambiguous" || typed.Details["type"] != "resolution" {
t.Fatalf("structured error = %#v", typed)
}
}
func TestCrossPlatformCoverageMessagesSendChatQueryResolvesAllPagesBeforeWrite(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":[{"title":"项目群-归档","openConversationId":"archive"}],"hasMore":true,"nextCursor":"page-2"}`,
`{"result":[{"title":"项目群","openConversationId":"active"}],"hasMore":false}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user",
"--chat-query", "项目群", "--text", "你好", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 || fake.calls[0].tool != "search_groups" ||
fake.calls[1].tool != "search_groups" || fake.calls[2].tool != "send_personal_message" {
t.Fatalf("calls = %#v, want two resolution pages then one write", fake.calls)
}
if fake.calls[1].args["cursor"] != "page-2" || fake.calls[2].args["openConversationId"] != "active" {
t.Fatalf("calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendIncompleteChatResolutionHasNoWrite(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}],"hasMore":true}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user",
"--chat-query", "项目群", "--text", "你好", "--yes",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error = %#v", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_groups" {
t.Fatalf("incomplete resolution reached write: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendDryRunUsesRealNaturalTargetResolution(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user",
"--chat-query", "项目群", "--text", "你好", "--dry-run", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 {
t.Fatalf("dry-run calls = %#v, want one read-only resolution", fake.calls)
}
if fake.calls[0].product != "im" || fake.calls[0].tool != "search_groups" {
t.Fatalf("dry-run resolution = %#v", fake.calls[0])
}
}
func TestCrossPlatformCoverageMessagesSendRejectsNaturalTargetForUnsupportedIdentity(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "r",
"--chat-query", "项目群", "--text", "你好", "--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("bot natural target unexpectedly accepted")
}
if len(fake.calls) != 0 {
t.Fatalf("invalid identity reached lower service: %#v", fake.calls)
}
}
+2 -203
View File
@@ -38,98 +38,6 @@ import (
"strings"
)
// MessageListContractVersion identifies the additive, compatibility-preserving
// public envelope shared by message list/search/mget/thread projections.
const MessageListContractVersion = "im.message-list.v1"
// MessageResultContract is the reviewed additive contract shared by message
// list, search, mget, @me and thread projections. Keep this descriptor small:
// Runtime owns the values, while Skill references and policy checks consume a
// copy of these field names so prose cannot silently invent another result
// shape.
type MessageResultContract struct {
Version string
MessageFields []string
EnvelopeFields []string
}
var messageResultContractV1 = MessageResultContract{
Version: MessageListContractVersion,
MessageFields: []string{
"messageId",
"conversationId",
"threadId",
"sender",
"senderId",
"senderType",
"messageType",
"text",
"createTime",
"updateTime",
"reactions",
"quotedMessage",
"forwarded",
"resourceRefs",
},
EnvelopeFields: []string{
"contractVersion",
"messages",
"count",
"pagesFetched",
"paginationKnown",
"complete",
"hasMore",
"nextPage",
"stopReason",
"truncatedByPageLimit",
"truncatedByResultLimit",
"failedCount",
"failures",
"partial",
"resourceDownloads",
},
}
// CurrentMessageResultContract returns defensive copies so callers cannot
// mutate the process-wide reviewed descriptor.
func CurrentMessageResultContract() MessageResultContract {
contract := messageResultContractV1
contract.MessageFields = append([]string(nil), contract.MessageFields...)
contract.EnvelopeFields = append([]string(nil), contract.EnvelopeFields...)
return contract
}
// NewMessageListPayload initializes the common result ledger before a caller
// adds pagination or resource-download facts.
func NewMessageListPayload(messages []map[string]any) map[string]any {
if messages == nil {
messages = []map[string]any{}
}
return map[string]any{
"contractVersion": MessageListContractVersion,
"messages": messages,
"count": len(messages),
"pagesFetched": 0,
"paginationKnown": false,
"complete": false,
"hasMore": false,
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
}
}
// StableMessageID returns the normalized message identity used for
// cross-page deduplication. An empty value means the lower response did not
// publish a stable identity; callers must keep that row rather than guessing.
func StableMessageID(message map[string]any) string {
value := MessageID(message)
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
// Sender reads a message's speaker display name, tolerating common sender-name
// keys. The message-list responses carry the display name under the bare
// "sender" key (verified live), so it is probed first; the remaining aliases and
@@ -242,88 +150,6 @@ func MessageType(m map[string]any) any {
return firstMessageValue(m, "msgType", "messageType", "message_type", "type")
}
// SenderID preserves the stable sender identity without replacing the legacy
// scalar sender display field. Nested sender records and both userId families
// are accepted because list/search/mget currently expose different shapes.
func SenderID(m map[string]any) any {
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
if value := firstMessageValue(nested,
"openDingTalkId", "openDingtalkId", "userId", "senderId", "id"); value != nil {
return value
}
}
}
return firstMessageValue(m,
"senderOpenDingTalkId", "senderOpenDingtalkId", "senderUserId",
"senderId", "sender_id", "senderStaffId", "openDingTalkId", "userId")
}
// SenderType returns only an explicitly published lower sender type. It does
// not guess that every sender identity is a user because bot/system messages
// can share the same generic senderId key.
func SenderType(m map[string]any) any {
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
if value := firstMessageValue(nested, "senderType", "type", "entityType"); value != nil {
return value
}
}
}
return firstMessageValue(m, "senderType", "sender_type", "fromType", "from_type")
}
// ProjectMessageV1 is the single compatibility-preserving core projection for
// list, search, mget, @me, and thread readers. Public wrappers may retain
// legacy aliases such as time or msgType, but the underlying identity,
// context, reaction, quote, forward, and resource semantics come from here.
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
row := map[string]any{
"sender": Sender(m),
"text": Text(m),
"createTime": CreateTime(m),
}
if value := MessageID(m); value != nil {
row["messageId"] = value
}
if value := ConversationID(m); value != nil {
row["conversationId"] = value
}
if value := ThreadID(m); value != nil {
row["threadId"] = value
}
if value := SenderID(m); value != nil {
row["senderId"] = value
}
if value := SenderType(m); value != nil {
row["senderType"] = value
}
if value := MessageType(m); value != nil {
row["messageType"] = value
}
if value := UpdateTime(m); value != nil {
row["updateTime"] = value
}
if includeReactions {
if reactions := Reactions(m); len(reactions) > 0 {
row["reactions"] = reactions
}
}
if quoted := QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
return ProjectMessageV1(item, includeReactions)
}
if forwarded := Forwarded(m, projectForwarded); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// QuotedMessage projects one level of quoted/replied-to context. It is
// deliberately non-recursive: a reply chain may be arbitrarily deep or even
// cyclic after gateway reshaping, while an Agent primarily needs the quoted
@@ -779,35 +605,13 @@ func ApplyPagination(payload, data map[string]any) {
// message-list contract paginates with the boundary message createTime, so the
// resume object uses exactly that accepted parameter.
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
payload["contractVersion"] = MessageListContractVersion
payload["pagesFetched"] = 1
payload["enrichedCount"] = 0
payload["failedCount"] = 0
payload["failures"] = []map[string]any{}
payload["hasMore"] = false
payload["complete"] = false
page := Pagination(data)
if len(page) == 0 {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层未返回可靠的 hasMore/nextCursor,无法证明结果完整",
}}
return
}
value, hasMoreKnown := page["hasMore"]
if !hasMoreKnown {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层仅返回 cursor、未返回 hasMore,无法证明结果完整",
}}
return
if value, ok := page["hasMore"]; ok {
payload["hasMore"] = value
}
payload["paginationKnown"] = true
payload["hasMore"] = value
if value, ok := page["complete"]; ok {
payload["complete"] = value
}
@@ -817,11 +621,6 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string]
}
boundary := CreateTime(messages[len(messages)-1])
if boundary == nil {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime",
}}
return
}
next := map[string]any{"time": boundary}
+15 -83
View File
@@ -18,7 +18,7 @@ import (
"testing"
)
func TestCrossPlatformCoverageSender(t *testing.T) {
func TestSender(t *testing.T) {
// The display name lives under the bare "sender" key.
if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" {
t.Fatalf("sender = %v, want 念晨", got)
@@ -48,37 +48,7 @@ func TestCrossPlatformCoverageSender(t *testing.T) {
}
}
func TestCrossPlatformCoverageProjectMessageV1PublishesSharedIdentityAndContext(t *testing.T) {
row := ProjectMessageV1(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"openConvThreadId": "thread-1",
"sender": map[string]any{
"name": "张三",
"openDingTalkId": "D1",
"senderType": "user",
},
"msgType": "text",
"content": "你好",
"createTime": "2026-08-03 10:00:00",
}, true)
for key, want := range map[string]any{
"messageId": "msg-1",
"conversationId": "cid-1",
"threadId": "thread-1",
"sender": "张三",
"senderId": "D1",
"senderType": "user",
"messageType": "text",
"text": "你好",
} {
if row[key] != want {
t.Errorf("%s = %#v, want %#v; row=%#v", key, row[key], want, row)
}
}
}
func TestCrossPlatformCoverageCleanText(t *testing.T) {
func TestCleanText(t *testing.T) {
// Out-of-office auto-reply: readable body lives in items[].data.text; the
// decorative preview/config JSON lines and "empty" placeholder are dropped.
autoReply := "* 仅你和对方可见\n" +
@@ -128,7 +98,7 @@ func TestCrossPlatformCoverageCleanText(t *testing.T) {
}
}
func TestCrossPlatformCoverageIsEncryptedAndMarker(t *testing.T) {
func TestIsEncryptedAndMarker(t *testing.T) {
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
"||2||1||196"
@@ -151,7 +121,7 @@ func TestCrossPlatformCoverageIsEncryptedAndMarker(t *testing.T) {
}
}
func TestCrossPlatformCoverageText(t *testing.T) {
func TestText(t *testing.T) {
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
t.Errorf("Text string = %v", got)
}
@@ -166,7 +136,7 @@ func TestCrossPlatformCoverageText(t *testing.T) {
}
}
func TestCrossPlatformCoverageCreateTime(t *testing.T) {
func TestCreateTime(t *testing.T) {
if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" {
t.Errorf("CreateTime = %v", got)
}
@@ -175,7 +145,7 @@ func TestCrossPlatformCoverageCreateTime(t *testing.T) {
}
}
func TestCrossPlatformCoverageStableMessageIdentity(t *testing.T) {
func TestStableMessageIdentity(t *testing.T) {
message := map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
@@ -196,31 +166,7 @@ func TestCrossPlatformCoverageStableMessageIdentity(t *testing.T) {
}
}
func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing.T) {
contract := CurrentMessageResultContract()
if contract.Version != MessageListContractVersion || len(contract.MessageFields) == 0 || len(contract.EnvelopeFields) == 0 {
t.Fatalf("message result contract = %#v", contract)
}
contract.MessageFields[0] = "mutated"
contract.EnvelopeFields[0] = "mutated"
second := CurrentMessageResultContract()
if second.MessageFields[0] == "mutated" || second.EnvelopeFields[0] == "mutated" {
t.Fatal("message result contract leaked mutable storage")
}
payload := NewMessageListPayload(nil)
if payload["count"] != 0 || payload["messages"] == nil {
t.Fatalf("nil message ledger = %#v", payload)
}
if StableMessageID(map[string]any{}) != "" {
t.Fatal("missing message identity was fabricated")
}
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"nextCursor": "next"}}, nil, "older")
if payload["paginationKnown"] != false || payload["failedCount"] != 1 {
t.Fatalf("cursor-only pagination = %#v", payload)
}
}
func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) {
func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) {
got := QuotedMessage(map[string]any{
"quotedMessage": map[string]any{
"openMessageId": "quoted-1",
@@ -348,7 +294,7 @@ func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpdateTimeOmitsUneditedEcho(t *testing.T) {
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
if got := UpdateTime(map[string]any{
"createTime": "2026-07-19 13:37:03",
"updateTime": "2026-07-19 13:37:03",
@@ -363,7 +309,7 @@ func TestCrossPlatformCoverageUpdateTimeOmitsUneditedEcho(t *testing.T) {
}
}
func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T) {
func TestReactionsNormalizesEmotionReplyList(t *testing.T) {
got := Reactions(map[string]any{
"emotionReplyList": []any{
map[string]any{
@@ -399,7 +345,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
}
}
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
func TestApplyPaginationReadsNestedEnvelope(t *testing.T) {
payload := map[string]any{"count": 98}
ApplyPagination(payload, map[string]any{
"result": map[string]any{
@@ -423,7 +369,7 @@ func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
}
}
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{
@@ -443,21 +389,7 @@ func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t
}
}
func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older")
if payload["contractVersion"] != MessageListContractVersion ||
payload["complete"] != false || payload["paginationKnown"] != false ||
payload["failedCount"] != 1 {
t.Fatalf("unknown pagination contract = %#v", payload)
}
failures, _ := payload["failures"].([]map[string]any)
if len(failures) != 1 || failures[0]["stage"] != "pagination" {
t.Fatalf("unknown pagination failures = %#v", failures)
}
}
func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *testing.T) {
func TestResourcesBuildsActionableDownloadReferences(t *testing.T) {
resources := Resources(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
@@ -484,7 +416,7 @@ func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *tes
}
}
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
func TestResourcesReportsMissingDownloadContext(t *testing.T) {
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
if len(resources) != 1 {
t.Fatalf("resources = %#v", resources)
@@ -499,7 +431,7 @@ func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.
}
}
func TestCrossPlatformCoverageResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
resources := Resources(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
@@ -510,7 +442,7 @@ func TestCrossPlatformCoverageResourcesTextMediaIDRequiresWordBoundary(t *testin
}
}
func TestCrossPlatformCoverageForwarded(t *testing.T) {
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
row := map[string]any{"text": Text(m)}
-182
View File
@@ -1,182 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"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"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "doc " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: compositeInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{
"需要文件树移动、复制或普通钉盘文件操作时改用 drive;非文字文档按对象类型路由到 sheet、aitable、slides 或 wiki",
},
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: name,
CanonicalPath: "doc." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func withDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
return decl
}
func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, error) {
raw := rt.Str(flag)
if raw == "-" {
data, err := io.ReadAll(rt.Command().InOrStdin())
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err))
}
return string(data), nil
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
}
data, err := docReadFile(realPath)
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
return string(data), nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
apperrors.WithOperation(operation),
apperrors.WithReason(reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
}
func nestedString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if value := nestedString(inner, keys...); value != "" {
return value
}
}
}
return ""
}
func nestedMap(data map[string]any) map[string]any {
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
return nestedMap(inner)
}
}
return data
}
func stringSliceNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out
}
// blockIdentity normalizes the currently observed block response shapes. The
// element API returns element.id, while JSONML and older payloads use blockId
// or uuid. Callers pass an inherited parent identity for nested text maps.
func blockIdentity(values map[string]any, inherited string) string {
for _, key := range []string{"blockId", "id", "uuid"} {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return inherited
}
-795
View File
@@ -1,795 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"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/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
docGetwd = os.Getwd
docEvalSymlinks = filepath.EvalSymlinks
docRel = filepath.Rel
docReadFile = os.ReadFile
docMkdirTemp = os.MkdirTemp
docRemoveAll = os.RemoveAll
docDownload = localio.Download
)
var Create = shortcut.Shortcut{
Service: "doc",
Command: "+create",
Product: productDoc,
Description: "从 Markdown 或 JSONML 创建在线文字文档",
Intent: "当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: docContract(
"+create", "从 Markdown 或 JSONML 创建在线文字文档",
"当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
[]string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
contract.ParamDecl{Name: "folder", Property: "folderId"},
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
),
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@工作目录相对文件或 - 表示 stdin"},
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Tips: []string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
Execute: func(rt *shortcut.RuntimeContext) error {
content, err := readShortcutContent(rt, "content")
if err != nil {
return err
}
format := rt.Str("doc-format")
if format == "jsonml" && content != "" {
content, err = validateJSONML(content)
if err != nil {
return err
}
}
params := map[string]any{"name": rt.Str("name")}
if rt.Str("folder") != "" {
params["folderId"] = rt.Str("folder")
}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
if format == "markdown" && content != "" {
params["markdown"] = content
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)}))
}
created, err := rt.CallMCPWriteData(productDoc, "create_document", params)
if err != nil {
return err
}
nodeID := nestedString(created, "nodeId", "documentId", "id")
steps := []map[string]any{{"name": "create_document", "status": "success"}}
if format == "jsonml" && content != "" {
if nodeID == "" {
return docPartialWriteError(
"doc.create", "doc_create_missing_node_id", "resolve_created_document",
"创建文档成功但响应缺少 nodeId;JSONML 尚未写入,请先在钉钉中定位新文档,不要直接重试",
nil,
map[string]any{"nodeId": "", "docFormat": format},
append(steps, map[string]any{"name": "write_jsonml", "status": "not_started"}),
map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"},
)
}
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "format": "jsonml", "jsonml": content, "mode": "overwrite"}); err != nil {
return docPartialWriteError(
"doc.create", "doc_create_initial_content_failed", "write_jsonml",
fmt.Sprintf("文档已创建但 JSONML 写入失败(nodeId=%s);不要直接重试创建", nodeID),
err,
map[string]any{"nodeId": nodeID, "docFormat": format},
append(steps, map[string]any{"name": "write_jsonml", "status": "failed"}),
map[string]any{"available": true, "action": "delete_created_document", "nodeId": nodeID, "reason": "remove the empty document before retrying create"},
)
}
steps = append(steps, map[string]any{"name": "write_jsonml", "status": "success"})
}
return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created}, steps...))
},
}
var Fetch = shortcut.Shortcut{
Service: "doc",
Command: "+fetch",
Product: productDoc,
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
Intent: "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract(
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
"当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "detail", Type: shortcut.FlagString, Default: "simple", Desc: "输出细节", Enum: []string{"simple", "with-ids", "full"}},
{Name: "scope", Type: shortcut.FlagString, Default: "full", Desc: "读取范围;keyword 时 --keyword 不能为空", Enum: []string{"full", "outline", "range", "section", "keyword", "tags"}},
{Name: "start-block-id", Type: shortcut.FlagString, Desc: "range/section 起始块 ID"},
{Name: "end-block-id", Type: shortcut.FlagString, Desc: "range 结束块 ID"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "keyword 范围搜索词,不能为空,支持 foo|bar"},
{Name: "tags", Type: shortcut.FlagStringSlice, Desc: "tags 范围的 JSONML tag"},
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
},
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("revision") {
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
}
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"scope", "keyword"}, Description: "--scope keyword 时 --keyword 不能为空"}},
Execute: func(rt *shortcut.RuntimeContext) error {
format := "markdown"
if rt.Str("detail") != "simple" || rt.Str("scope") != "full" {
format = "jsonml"
}
params := map[string]any{"nodeId": rt.Str("node"), "format": format}
scope := rt.Str("scope")
if scope != "keyword" && scope != "full" {
params["scope"] = scope
}
if value := rt.Str("start-block-id"); value != "" {
params["startBlockId"] = value
}
if value := rt.Str("end-block-id"); value != "" {
params["endBlockId"] = value
}
if rt.Changed("tags") {
params["tags"] = rt.StrSlice("tags")
}
if rt.Changed("max-depth") {
params["maxDepth"] = rt.Int("max-depth")
}
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
if err != nil {
return err
}
if scope == "keyword" {
return rt.Output(projectKeywordMatches(data, rt.Str("keyword"), rt.Int("context-before"), rt.Int("context-after")))
}
return rt.Output(data)
},
}
var Inspect = shortcut.Shortcut{
Service: "doc",
Command: "+inspect",
Product: productDoc,
Description: "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
Intent: "当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+inspect", "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
"当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
[]string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "include-style", Type: shortcut.FlagBool, Desc: "附带封面和背景"},
{Name: "include-permissions", Type: shortcut.FlagBool, Desc: "附带权限列表"},
{Name: "include-history", Type: shortcut.FlagBool, Desc: "附带最近历史版本"},
{Name: "include-media", Type: shortcut.FlagBool, Desc: "附带正文媒体列表"},
{Name: "include-comments", Type: shortcut.FlagBool, Desc: "附带评论列表"},
},
Tips: []string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
result := map[string]any{}
info, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": node})
if err != nil {
return err
}
result["document"] = info
reads := []struct {
flag, key, product, tool string
params map[string]any
}{
{"include-style", "style", productDoc, "get_document_style", map[string]any{"nodeId": node}},
{"include-permissions", "permissions", productDoc, "list_permission", map[string]any{"nodeId": node}},
{"include-history", "history", productDoc, "list_doc_versions", map[string]any{"nodeId": node}},
{"include-media", "media", productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "jsonml"}},
{"include-comments", "comments", productComment, "list_comments", map[string]any{"nodeId": node}},
}
for _, read := range reads {
if !rt.Bool(read.flag) {
continue
}
value, callErr := rt.CallMCPData(read.product, read.tool, read.params)
if callErr != nil {
return callErr
}
result[read.key] = value
}
return rt.Output(docEnvelope("doc.inspect", result, map[string]any{"name": "inspect", "status": "success"}))
},
}
var Update = shortcut.Shortcut{
Service: "doc",
Command: "+update",
Product: productDoc,
Description: "追加、覆盖或按 block 精确更新文档内容",
Intent: "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+update", "追加、覆盖或按 block 精确更新文档内容",
"当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
[]string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
contract.ParamDecl{Name: "doc", Property: "node"},
contract.ParamDecl{Name: "text", Property: "content"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true, Aliases: []string{"doc"}, AliasesVisible: true},
{Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin;相关动作要求时不能为空", Aliases: []string{"text"}, AliasesVisible: true},
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
{Name: "block-id", Type: shortcut.FlagString, Desc: "目标或源 block ID;相关动作要求时不能为空"},
{Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID"},
{Name: "old", Type: shortcut.FlagString, Desc: "str_replace 原文字,不能为空"},
{Name: "new", Type: shortcut.FlagString, Desc: "str_replace 新文字;--old 不能为空,新值可为空但参数必须显式提供"},
{Name: "expected-revision", Type: shortcut.FlagInt, Desc: "best-effort 乐观 revision 检查"},
},
Tips: []string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
Validate: func(rt *shortcut.RuntimeContext) error {
command := rt.Str("command")
if command == "" {
return apperrors.NewValidation("缺少 --command")
}
if rt.StrFirst("node", "doc") == "" {
return apperrors.NewValidation("缺少 --node")
}
if command == "append" || command == "overwrite" || command == "block_insert_after" || command == "block_replace" {
if rt.StrFirst("content", "text") == "" {
return apperrors.NewValidation("该更新动作的 --content 不能为空")
}
}
if strings.HasPrefix(command, "block_") && command != "block_insert_after" && rt.Str("block-id") == "" {
return apperrors.NewValidation("该 block 操作必须提供 --block-id")
}
if command == "str_replace" && (rt.Str("old") == "" || !rt.Changed("new")) {
return apperrors.NewValidation("--command str_replace 必须同时提供 --old 和 --new")
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "old", "new"}, Description: "依 command 校验,所需文本参数不能为空"}},
Execute: executeUpdate,
}
var CheckpointUpdate = shortcut.Shortcut{
Service: "doc",
Command: "+checkpoint-update",
Product: productDoc,
Description: "先保存可回滚版本,再更新并读回验证",
Intent: "当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+checkpoint-update", "先保存可回滚版本,再更新并读回验证",
"当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
[]string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "更新模式", Enum: []string{"append", "overwrite"}},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin", Required: true},
},
Tips: []string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`},
Execute: func(rt *shortcut.RuntimeContext) error {
content, err := readShortcutContent(rt, "content")
if err != nil {
return err
}
plan := map[string]any{"nodeId": rt.Str("node"), "mode": rt.Str("mode"), "contentBytes": len(content), "steps": []string{"save_doc_version", "update_document", "get_document_content"}}
if rt.DryRun() {
plan["executed"] = false
return rt.Output(docEnvelope("doc.checkpoint_update", plan))
}
steps := []map[string]any{}
checkpoint, err := rt.CallMCPWriteData(productDoc, "save_doc_version", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
steps = append(steps, map[string]any{"name": "checkpoint", "status": "success"})
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": rt.Str("node"), "markdown": content, "mode": rt.Str("mode")}); err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "update", "doc_checkpoint_update_failed", err,
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
}
steps = append(steps, map[string]any{"name": "update", "status": "success"})
verified, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
if err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"verified": verified}, steps...))
},
}
var Export = shortcut.Shortcut{
Service: "doc",
Command: "+export",
Product: productDoc,
Description: "提交、轮询并安全下载在线文档导出文件",
Intent: "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+export", "提交、轮询并安全下载在线文档导出文件",
"当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
[]string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
{Name: "max-polls", Type: shortcut.FlagInt, Default: "30", Desc: "最大轮询次数"},
},
Tips: []string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Execute: executeExport,
}
var Import = shortcut.Shortcut{
Service: "doc",
Command: "+import",
Product: productDoc,
Description: "上传本地文件并等待转换成在线文档对象",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
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 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
[]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},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
{Name: "name", Type: shortcut.FlagString, Desc: "导入后名称"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"folder", "workspace"}, Description: "--folder 与 --workspace 至少提供一个导入目标"}},
Tips: []string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocImportShortcut(rt.Command()) },
}
func executeUpdate(rt *shortcut.RuntimeContext) error {
command := rt.Str("command")
contentFlag := "content"
if rt.Str("content") == "" && rt.Str("text") != "" {
contentFlag = "text"
}
content, err := readShortcutContent(rt, contentFlag)
if err != nil {
return err
}
if rt.Str("doc-format") == "jsonml" && content != "" {
content, err = validateJSONML(content)
if err != nil {
return err
}
}
nodeID := rt.StrFirst("node", "doc")
currentRevision := 0
if rt.Changed("expected-revision") {
current, revisionErr := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": "jsonml"})
if revisionErr != nil {
return revisionErr
}
var found bool
currentRevision, found = nestedRevision(current)
if !found {
return apperrors.NewAPI("REVISION_CONFLICT: 服务响应缺少当前 revision,无法安全执行乐观更新")
}
if expected := rt.Int("expected-revision"); currentRevision != expected {
return apperrors.NewValidation(fmt.Sprintf("REVISION_CONFLICT: 期望 revision %d,当前为 %d", expected, currentRevision))
}
}
plan := map[string]any{"nodeId": nodeID, "command": command, "blockId": rt.Str("block-id"), "afterBlockId": rt.Str("after-block-id"), "contentBytes": len(content)}
if rt.Changed("expected-revision") {
plan["expectedRevision"] = rt.Int("expected-revision")
plan["currentRevision"] = currentRevision
plan["optimisticCheck"] = "best_effort"
}
if rt.DryRun() {
plan["executed"] = false
return rt.Output(docEnvelope("doc.update", plan))
}
node := nodeID
switch command {
case "append", "overwrite":
params := map[string]any{"nodeId": node, "mode": command}
if rt.Str("doc-format") == "jsonml" {
if command == "append" {
return apperrors.NewValidation("JSONML 当前不支持 append")
}
params["format"], params["jsonml"] = "jsonml", content
} else {
params["markdown"] = content
}
return rt.CallMCP("update_document", params)
case "block_insert_after":
params := map[string]any{"nodeId": node, "referenceBlockId": rt.Str("after-block-id"), "where": "after"}
if rt.Str("doc-format") == "jsonml" {
params["format"], params["jsonml"] = "jsonml", content
} else {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return rt.CallMCP("insert_document_block", params)
case "block_replace":
params := map[string]any{"nodeId": node, "blockId": rt.Str("block-id")}
if rt.Str("doc-format") == "jsonml" {
params["format"], params["jsonml"] = "jsonml", content
} else {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return rt.CallMCP("update_document_block", params)
case "block_delete":
return rt.CallMCP("delete_document_block", map[string]any{"nodeId": node, "blockId": rt.Str("block-id")})
case "str_replace":
return executePlainTextReplace(rt, node)
case "block_copy_insert_after":
return executeBlockCopy(rt, node)
default:
return apperrors.NewValidation(fmt.Sprintf("不支持的 update command %q", command))
}
}
func nestedRevision(value any) (int, bool) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if normalized == "revision" || normalized == "version" || normalized == "versionnumber" {
switch number := child.(type) {
case float64:
if number >= 0 && number == float64(int(number)) {
return int(number), true
}
case json.Number:
parsed, err := number.Int64()
if err == nil && parsed >= 0 {
return int(parsed), true
}
case string:
var parsed int
if _, err := fmt.Sscan(strings.TrimSpace(number), &parsed); err == nil && parsed >= 0 {
return parsed, true
}
}
}
if revision, ok := nestedRevision(child); ok {
return revision, true
}
}
case []any:
for _, child := range typed {
if revision, ok := nestedRevision(child); ok {
return revision, true
}
}
}
return 0, false
}
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
oldText := rt.Str("old")
type match struct{ blockID, text string }
matches := []match{}
var walk func(any, string)
walk = func(value any, inheritedID string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
for key, child := range typed {
if key == "text" {
if text, ok := child.(string); ok && strings.Contains(text, oldText) && blockID != "" {
matches = append(matches, match{blockID: blockID, text: text})
}
}
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(data, "")
if len(matches) != 1 {
return apperrors.NewValidation(fmt.Sprintf("UNSAFE_RICH_TEXT_REPLACE: 需要唯一普通文本块匹配,实际 %d 处", len(matches)))
}
updated := strings.Replace(matches[0].text, oldText, rt.Str("new"), 1)
return rt.CallMCP("update_document_block", map[string]any{"nodeId": nodeID, "blockId": matches[0].blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}})
}
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
if err != nil {
return err
}
block := findBlock(data, rt.Str("block-id"))
if block == nil {
return apperrors.NewValidation("DOCUMENT_NOT_FOUND: 未找到要复制的 block")
}
if containsResourceReference(block) {
return apperrors.NewValidation("UNSUPPORTED_RESOURCE_TYPE: 含资源引用的 block 暂不支持复制")
}
stripBlockIDs(block)
return rt.CallMCP("insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": rt.Str("after-block-id"), "where": "after", "element": block})
}
func executeExport(rt *shortcut.RuntimeContext) error {
plan := map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format"), "output": rt.Str("output")}
if rt.DryRun() {
plan["executed"] = false
plan["steps"] = []string{"submit_export_job", "query_export_job", "safe_atomic_download"}
return rt.Output(docEnvelope("doc.export", plan))
}
submit, err := rt.CallMCPData(productDoc, "submit_export_job", map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format")})
if err != nil {
return err
}
jobID := nestedString(submit, "jobId", "jobID")
if jobID == "" {
return apperrors.NewAPI("导出任务响应缺少 jobId")
}
maxPolls := rt.Int("max-polls")
if maxPolls <= 0 {
maxPolls = 30
}
var query map[string]any
for attempt := 1; attempt <= maxPolls; attempt++ {
query, err = rt.CallMCPData(productDoc, "query_export_job", map[string]any{"jobId": jobID})
if err != nil {
return err
}
status := strings.ToUpper(nestedString(query, "status"))
if status == "SUCCESS" {
break
}
if status != "PROCESSING" {
return apperrors.NewAPI(fmt.Sprintf("导出任务失败 (jobId=%s, status=%s): %s", jobID, status, nestedString(query, "message")))
}
if attempt == maxPolls {
return apperrors.NewAPI(fmt.Sprintf("导出任务超时 (jobId=%s),可用 doc +export-get 恢复查询", jobID))
}
timer := time.NewTimer(time.Duration(min(attempt, 5)) * time.Second)
select {
case <-rt.Command().Context().Done():
timer.Stop()
return rt.Command().Context().Err()
case <-timer.C:
}
}
downloadURL := nestedString(query, "downloadUrl", "resourceUrl")
if downloadURL == "" {
return apperrors.NewAPI(fmt.Sprintf("导出成功但响应缺少 downloadUrl (jobId=%s)", jobID))
}
cwd, err := docGetwd()
if err != nil {
return err
}
ext := map[string]string{"docx": ".docx", "markdown": ".md", "pdf": ".pdf"}[rt.Str("export-format")]
preferred := "document" + ext
result, err := docDownload(rt.Command().Context(), downloadURL, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferred})
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.export", map[string]any{"jobId": jobID, "localPath": result.RelativePath, "sizeBytes": result.SizeBytes},
map[string]any{"name": "submit", "status": "success"}, map[string]any{"name": "poll", "status": "success"}, map[string]any{"name": "download", "status": "success"}))
}
func projectKeywordMatches(data map[string]any, rawQuery string, before, after int) map[string]any {
queries := stringSliceNonEmpty(strings.Split(rawQuery, "|"))
if before <= 0 {
before = 80
}
if after <= 0 {
after = 120
}
matches := []map[string]any{}
appendTextMatch := func(text, blockID string) {
textRunes := []rune(text)
foldedText := foldRunes(textRunes)
for _, query := range queries {
foldedQuery := foldRunes([]rune(query))
index := indexRunes(foldedText, foldedQuery)
if index < 0 {
continue
}
start, end := max(0, index-before), min(len(textRunes), index+len(foldedQuery)+after)
matches = append(matches, map[string]any{
"blockId": blockID, "topBlockId": blockID, "parentBlockPath": []string{},
"content": string(textRunes[start:end]), "truncated": start > 0 || end < len(textRunes),
})
return
}
}
var walk func(any, string)
walk = func(value any, inheritedID string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
for key, child := range typed {
if key == "jsonml" {
if raw, ok := child.(string); ok {
var decoded any
if json.Unmarshal([]byte(raw), &decoded) == nil {
walk(decoded, blockID)
continue
}
}
}
if key == "text" {
if text, ok := child.(string); ok {
appendTextMatch(text, blockID)
continue
}
}
walk(child, blockID)
}
case []any:
blockID := inheritedID
start := 0
if len(typed) >= 2 {
if _, isTag := typed[0].(string); isTag {
start = 2
if attrs, ok := typed[1].(map[string]any); ok {
blockID = blockIdentity(attrs, blockID)
}
}
}
for _, child := range typed[start:] {
walk(child, blockID)
}
case string:
appendTextMatch(typed, inheritedID)
}
}
walk(data, "")
return map[string]any{"count": len(matches), "matches": matches}
}
func foldRunes(value []rune) []rune {
folded := make([]rune, len(value))
for index, char := range value {
folded[index] = unicode.ToLower(char)
}
return folded
}
func indexRunes(value, target []rune) int {
if len(target) == 0 || len(target) > len(value) {
return -1
}
for start := 0; start+len(target) <= len(value); start++ {
matched := true
for offset := range target {
if value[start+offset] != target[offset] {
matched = false
break
}
}
if matched {
return start
}
}
return -1
}
func checkpointPartialWriteError(nodeID string, checkpoint map[string]any, stage, reason string, cause error, steps []map[string]any) error {
data := map[string]any{"nodeId": nodeID, "checkpointSaved": true}
compensation := map[string]any{
"available": true,
"action": "revert_to_checkpoint",
"nodeId": nodeID,
"reason": "a checkpoint was saved before the update started",
}
if version, ok := nestedRevision(checkpoint); ok {
data["checkpointVersion"] = version
compensation["version"] = version
}
return docPartialWriteError(
"doc.checkpoint_update", reason, stage,
fmt.Sprintf("checkpoint-update 在 %s 阶段失败;恢复点已保存,nodeId=%s,请勿直接重试整个复合命令", stage, nodeID),
cause, data, steps, compensation,
)
}
func findBlock(value any, target string) map[string]any {
switch typed := value.(type) {
case map[string]any:
if blockIdentity(typed, "") == target {
copy := map[string]any{}
for key, value := range typed {
copy[key] = value
}
return copy
}
for _, child := range typed {
if found := findBlock(child, target); found != nil {
return found
}
}
case []any:
for _, child := range typed {
if found := findBlock(child, target); found != nil {
return found
}
}
}
return nil
}
func containsResourceReference(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if (key == "resourceId" || key == "resourceUrl" || key == "src") && fmt.Sprint(child) != "" {
return true
}
if containsResourceReference(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsResourceReference(child) {
return true
}
}
}
return false
}
func stripBlockIDs(value any) {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"blockId", "id", "uuid"} {
delete(typed, key)
}
for _, child := range typed {
stripBlockIDs(child)
}
case []any:
for _, child := range typed {
stripBlockIDs(child)
}
}
}
func init() {
_ = json.Valid
_ = filepath.Separator
shortcut.Register(Create, Fetch, Inspect, Update, CheckpointUpdate, Export, Import)
}
-8
View File
@@ -982,11 +982,6 @@ var TemplateApply = shortcut.Shortcut{
}
func init() {
// Expert/recovery leaves remain callable without entering Agent discovery.
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -998,9 +993,6 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
VersionSave,
VersionList,
VersionRevert,
-763
View File
@@ -1,763 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
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/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type docCoverageCaller struct {
failAt int
calls int
responses map[string][]map[string]any
ctx context.Context
history []docCoverageCall
}
type docCoverageCall struct {
tool string
params map[string]any
}
type docCoverageErrorReader struct{}
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
f.calls++
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
if f.failAt == f.calls {
return nil, errors.New("injected doc coverage failure")
}
value := docCoveragePayload(tool)
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
f.responses[tool] = queue[1:]
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
}
func (f *docCoverageCaller) Format() string { return "json" }
func (f *docCoverageCaller) DryRun() bool { return false }
func (f *docCoverageCaller) Fields() string { return "" }
func (f *docCoverageCaller) JQ() string { return "" }
func docCoveragePayload(tool string) map[string]any {
switch tool {
case "create_document":
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
case "get_document_content":
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
case "list_document_blocks":
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
case "submit_export_job":
return map[string]any{"jobId": "job-1"}
case "query_export_job":
return map[string]any{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/export.docx"}
case "list_doc_versions":
return map[string]any{"versions": []any{map[string]any{"version": 3.0}, map[string]any{"versionNumber": "4"}}}
case "search_doc_templates":
return map[string]any{"templates": []any{map[string]any{"templateId": "template-1"}}}
case "get_document_style":
return map[string]any{"data": map[string]any{"cover": map[string]any{"resourceId": "resource-1", "imageUrl": "https://download.dingtalk.com/cover.png"}}}
case "download_doc_attachment":
return map[string]any{"downloadUrl": "https://download.dingtalk.com/file.bin", "fileName": "file.bin", "headers": map[string]any{"x-test": "ok", "ignored": 1}}
case "list_comments":
return map[string]any{"commentList": []any{map[string]any{"commentKey": "comment-1", "content": "review", "quote": "alpha"}}}
default:
return map[string]any{"ok": true, "result": map[string]any{"id": "id-1"}}
}
}
func runDocCoverage(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, args ...string) error {
return runDocCoverageInput(t, declaration, caller, strings.NewReader(""), args...)
}
func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, args ...string) error {
return runDocCoveragePath(t, declaration, caller, input, declaration.Command, args...)
}
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "doc"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(input)
if caller.ctx != nil {
root.SetContext(caller.ctx)
}
root.SetArgs(append([]string{"doc", commandPath}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
t.Fatalf("nestedRevision = %d/%v", got, ok)
}
blocks := map[string]any{"blocks": []any{
map[string]any{"element": map[string]any{
"id": "block-1", "paragraph": map[string]any{"text": "前缀😀真实追加:beta。后缀"},
}},
}}
matches := findSelectionMatches(blocks, "真实追加:beta。")
if len(matches) != 1 {
t.Fatalf("matches = %#v", matches)
}
if got := matches[0]; got.blockID != "block-1" || got.start != 4 || got.end != 14 {
t.Fatalf("selection match = %#v", got)
}
jsonml := `["root",{},["p",{"uuid":"block-jsonml"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"旧入口兼容追加:gamma。"]]]]`
projected := projectKeywordMatches(map[string]any{"jsonml": jsonml}, "gamma", 80, 120)
if projected["count"] != 1 {
t.Fatalf("keyword projection = %#v", projected)
}
rows := projected["matches"].([]map[string]any)
if rows[0]["blockId"] != "block-jsonml" || rows[0]["content"] != "旧入口兼容追加:gamma。" {
t.Fatalf("keyword row = %#v", rows[0])
}
unicodeProjection := projectKeywordMatches(map[string]any{"id": "unicode-block", "text": "KABtargetCD"}, "TARGET", 1, 1)
unicodeRows := unicodeProjection["matches"].([]map[string]any)
if len(unicodeRows) != 1 || unicodeRows[0]["content"] != "BtargetC" || !utf8.ValidString(unicodeRows[0]["content"].(string)) {
t.Fatalf("unicode keyword projection = %#v", unicodeProjection)
}
}
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
t.Helper()
if err == nil {
t.Fatal("partial write unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("partial write error = %#v", err)
}
if typed.Reason != reason || typed.FailureStage != stage || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("partial write metadata = %#v", typed)
}
if typed.Details["status"] != "partial_success" {
t.Fatalf("partial write details = %#v", typed.Details)
}
data, _ := typed.Details["data"].(map[string]any)
if data["nodeId"] != nodeID {
t.Fatalf("partial write data = %#v", data)
}
steps, _ := typed.Details["steps"].([]map[string]any)
if len(steps) != wantSteps || steps[0]["status"] != "success" || steps[len(steps)-1]["status"] == "success" {
t.Fatalf("partial write steps = %#v", steps)
}
return typed
}
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
compensation, _ := typed.Details["compensation"].(map[string]any)
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
t.Fatalf("create compensation=%#v history=%#v", compensation, create.history)
}
checkpointUpdate := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{
"save_doc_version": {{"version": 7.0}},
}}
err = runDocCoverage(t, CheckpointUpdate, checkpointUpdate, "--node", "n", "--content", "body", "--yes")
typed = assertPartial(t, err, "doc_checkpoint_update_failed", "update", "n", 3)
data, _ := typed.Details["data"].(map[string]any)
compensation, _ = typed.Details["compensation"].(map[string]any)
if data["checkpointVersion"] != 7 || compensation["version"] != 7 {
t.Fatalf("checkpoint recovery metadata data=%#v compensation=%#v", data, compensation)
}
checkpointVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
}
func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) {
tests := []struct {
name string
args []string
wantTools []string
}{
{
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
wantTools: []string{"list_document_blocks", "update_document_block"},
},
{
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
wantTools: []string{"list_document_blocks", "insert_document_block"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.history) != len(tc.wantTools) {
t.Fatalf("calls = %#v", caller.history)
}
for index, call := range caller.history {
if call.tool != tc.wantTools[index] || call.params["nodeId"] != "alias-node" {
t.Fatalf("call %d = %#v, want tool=%s nodeId=alias-node", index, call, tc.wantTools[index])
}
}
})
}
}
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
tests := []struct {
name string
text string
selection string
want int
}{
{name: "repeated omitted range", text: "left A right; left B right", selection: "left...right", want: 3},
{name: "empty prefix", text: "one right; two right", selection: "...right", want: 2},
{name: "empty suffix", text: "left one; left two", selection: "left...", want: 2},
{name: "both empty anchors", text: "whole block", selection: "...", want: 1},
{name: "empty block", text: "", selection: "...", want: 0},
{name: "overlapping literal", text: "aaa", selection: "aa", want: 2},
{name: "empty selection", text: "text", selection: "", want: 0},
{name: "missing prefix", text: "text", selection: "left...right", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
matches := findSelectionMatches(map[string]any{"id": "block", "text": tc.text}, tc.selection)
if len(matches) != tc.want {
t.Fatalf("matches = %#v, want %d", matches, tc.want)
}
})
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {{"items": []any{map[string]any{"id": "block", "text": "left A right; left B right"}}}},
}}
err := runDocCoverage(t, CommentCreate, caller, "--node", "n", "--content", "review", "--selection", "left...right", "--yes")
if err == nil || !strings.Contains(err.Error(), "AMBIGUOUS_SELECTION") {
t.Fatalf("same-block ambiguity error = %v", err)
}
if len(caller.history) != 1 || caller.history[0].tool != "list_document_blocks" {
t.Fatalf("ambiguous selection reached a write: %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocDestructiveConfirmationBoundaries(t *testing.T) {
tests := []struct {
name string
decl shortcut.Shortcut
args []string
want []docCoverageCall
}{
{
name: "comment delete",
decl: CommentDelete,
args: []string{"--node", "n", "--comment-key", "c"},
want: []docCoverageCall{{tool: "delete_comment", params: map[string]any{"nodeId": "n", "commentKey": "c"}}},
},
{
name: "resource delete",
decl: ResourceDelete,
args: []string{"--node", "n"},
want: []docCoverageCall{{tool: "update_document_style", params: map[string]any{"nodeId": "n", "cover": map[string]any{"action": "clear"}}}},
},
{
name: "history revert",
decl: VersionRevert,
args: []string{"--node", "n", "--version", "3"},
want: []docCoverageCall{
{tool: "list_doc_versions", params: map[string]any{"nodeId": "n"}},
{tool: "revert_doc_version", params: map[string]any{"nodeId": "n", "version": 3}},
{tool: "get_document_info", params: map[string]any{"nodeId": "n"}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
unconfirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, tc.decl, unconfirmed, tc.args...); err == nil {
t.Fatal("destructive shortcut without --yes must reject")
}
if unconfirmed.calls != 0 || len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed shortcut called MCP: %#v", unconfirmed.history)
}
confirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, tc.decl, confirmed, append(tc.args, "--yes")...); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(confirmed.history, tc.want) {
t.Fatalf("confirmed calls = %#v, want %#v", confirmed.history, tc.want)
}
})
}
}
func TestCrossPlatformCoverageReviewInfersInlineBlockFromUniqueQuote(t *testing.T) {
comments := map[string]any{"commentList": []any{
map[string]any{"commentKey": "inline", "content": "review", "isGlobal": false, "quote": "真实追加:beta。"},
map[string]any{"commentKey": "global", "content": "global", "isGlobal": true},
}}
blocks := map[string]any{"blocks": []any{
map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "真实追加:beta。"}}},
}}
items := projectReviewComments(comments, blocks)
if len(items) != 2 {
t.Fatalf("review items = %#v", items)
}
if items[0]["blockId"] != "block-1" || items[0]["context"] != "真实追加:beta。" {
t.Fatalf("inline review = %#v", items[0])
}
if items[1]["blockId"] != "" {
t.Fatalf("global review = %#v", items[1])
}
}
func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.json", []byte(`["root",{},"body"]`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("body.md", []byte("from file"), 0o600); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
cmd shortcut.Shortcut
args []string
}{
{"create markdown", Create, []string{"--name", "n", "--content", "body", "--folder", "f", "--workspace", "w"}},
{"create dry", Create, []string{"--name", "n", "--content", "body", "--dry-run"}},
{"create jsonml file", Create, []string{"--name", "n", "--content", "@body.json", "--doc-format", "jsonml"}},
{"create stdin", Create, []string{"--name", "n", "--content", "-"}},
{"fetch simple", Fetch, []string{"--node", "n"}},
{"fetch keyword", Fetch, []string{"--node", "n", "--detail", "full", "--scope", "keyword", "--keyword", "alpha|none", "--context-before", "1", "--context-after", "1"}},
{"fetch scoped", Fetch, []string{"--node", "n", "--scope", "range", "--start-block-id", "a", "--end-block-id", "b", "--tags", "p,h1", "--max-depth", "2"}},
{"inspect base", Inspect, []string{"--node", "n"}},
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
}
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{RelativePath: "out.docx", SizeBytes: 7}, nil
})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
var err error
if tc.name == "create stdin" {
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
} else {
err = runDocCoverage(t, tc.cmd, caller, tc.args...)
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
})
}
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
for failAt := 1; failAt <= 7; failAt++ {
args := map[string][]string{
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
"+export": {"--node", "n", "--output", "out.docx"},
}[command.Command]
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
}
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(t.TempDir()), "outside.md")
_ = outside
badCases := []struct {
cmd shortcut.Shortcut
args []string
}{
{Create, []string{"--name", "n", "--content", "@"}},
{Create, []string{"--name", "n", "--content", "@/absolute"}},
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
{Update, []string{"--command", "append", "--content", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
}
for _, tc := range badCases {
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
}
}
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
t.Fatal("jsonml create without node id succeeded")
}
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("revision conflict succeeded")
}
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("missing revision succeeded")
}
for _, value := range []any{
map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"},
map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none",
} {
_, _ = nestedRevision(value)
}
if _, err := validateJSONML(`[`); err == nil {
t.Fatal("invalid jsonml succeeded")
}
if _, err := validateJSONML(`{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
_ = stringSliceNonEmpty([]string{"", " a "})
blocks := map[string]any{"items": []any{map[string]any{"id": "b", "text": "alpha alpha"}, map[string]any{"id": "resource", "src": "x"}}}
_ = projectKeywordMatches(blocks, "alpha|beta", -1, -1)
_ = projectKeywordMatches(map[string]any{"jsonml": "bad"}, "none", 1, 1)
_ = findBlock(blocks, "b")
_ = findBlock([]any{blocks}, "missing")
_ = containsResourceReference(blocks)
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
stripBlockIDs(blocks)
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("visible update flag aliases: %v", err)
}
missingNode := Update
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
missingNode.Flags[0].Required = false
if err := runDocCoverage(t, missingNode, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--command", "append", "--content", "x", "--yes"); err == nil {
t.Fatal("custom missing-node validation was not reached")
}
unknown := Update
unknown.Flags = append([]shortcut.Flag(nil), Update.Flags...)
unknown.Flags[1].Enum = append(append([]string(nil), unknown.Flags[1].Enum...), "bogus")
if err := runDocCoverage(t, unknown, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "bogus", "--yes"); err == nil {
t.Fatal("unknown update command succeeded")
}
_ = runDocCoverageInput(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, docCoverageErrorReader{}, "--name", "n", "--content", "-")
for _, seamCase := range []struct {
name string
run func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"eval-base", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"eval-file", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(value string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("eval file")
}
return value, nil
})
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"rel", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"read", func(t *testing.T) {
testseam.Swap(t, &docReadFile, func(string) ([]byte, error) { return nil, errors.New("read") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
} {
t.Run(seamCase.name, seamCase.run)
}
_ = runDocCoverage(t, CheckpointUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--content", "@missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "append", "--content", "@missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "overwrite", "--content", "bad", "--doc-format", "jsonml", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "alpha"}, map[string]any{"id": "b", "text": "alpha"}}}}}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"ok": true}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "block-1", "resourceId": "r"}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
for _, response := range []map[string]any{
{"ok": true},
{"jobId": "j"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
}
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
}
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{RelativePath: "artifact.bin", SizeBytes: 9}, nil
})
commands := []struct {
decl shortcut.Shortcut
args []string
}{
{VersionList, []string{"--node", "n", "--page-size", "2", "--page-token", "p"}},
{VersionList, []string{"--node", "n", "--limit", "2", "--cursor", "p"}},
{VersionRevert, []string{"--node", "n", "--version", "3", "--yes"}},
{VersionRevert, []string{"--node", "n", "--version", "3", "--dry-run", "--yes"}},
{CreateFromTemplate, []string{"--template-id", "t", "--name", "n", "--folder", "f", "--workspace", "w"}},
{CreateFromTemplate, []string{"--query", "q", "--source", "PUBLIC", "--dry-run"}},
{Review, []string{"--node", "n"}},
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
{MediaList, []string{"--node", "n"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r", "--dry-run"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin", "--dry-run"}},
{ResourceDownload, []string{"--node", "n", "--output", "cover.png"}},
{ResourceDownload, []string{"--node", "n", "--output", "cover.png", "--dry-run"}},
{ResourceDelete, []string{"--node", "n", "--dry-run", "--yes"}},
{BackgroundUpdate, []string{"--node", "n", "--color", "#ABCDEF"}},
{BackgroundDelete, []string{"--node", "n", "--dry-run", "--yes"}},
{BackgroundDelete, []string{"--node", "n", "--yes"}},
{ResourceDelete, []string{"--node", "n", "--yes"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--yes"}},
}
for _, item := range commands {
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err != nil {
t.Errorf("%s: %v", item.decl.Command, err)
}
}
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
args := map[string][]string{
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
"+create-from-template": {"--query", "q"},
"+review": {"--node", "n"},
"+media-list": {"--node", "n"},
"+media-download": {"--node", "n", "--resource-id", "r", "--output", "m.bin"},
"+resource-download": {"--node", "n", "--output", "cover.png"},
}[declaration.Command]
for failAt := 1; failAt <= 4; failAt++ {
_ = runDocCoverage(t, declaration, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
for _, value := range []any{
map[string]any{"version": 3.0}, map[string]any{"version": 3.5}, map[string]any{"version": "3"}, map[string]any{"version": "bad"},
[]any{map[string]any{"revision": 3.0}}, "none",
} {
_ = containsVersion(value, 3)
}
_ = collectTemplateIDs(map[string]any{"template_id": "t1", "nested": []any{map[string]any{"templateId": "t1"}, map[string]any{"templateId": "t2"}, "x"}})
_ = collectTemplateIDs("none")
_ = collectMediaItems(map[string]any{"id": "b", "resourceId": "r", "src": "u", "name": "n", "type": "file", "mimeType": "x", "viewType": "v", "children": []any{map[string]any{"resourceUrl": "u2"}}})
_ = nestedStringDeep([]any{map[string]any{"x": map[string]any{"url": " u "}}}, "url")
_ = nestedStringDeep("none", "url")
badComments := [][]string{
{"--node", "n", "--content", "x", "--block-id", "b", "--yes"},
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "2", "--end", "1", "--yes"},
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selection", "x", "--yes"},
}
for _, args := range badComments {
if err := runDocCoverage(t, CommentCreate, &docCoverageCaller{responses: map[string][]map[string]any{}}, args...); err == nil {
t.Errorf("invalid comment args succeeded: %#v", args)
}
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "x"}, map[string]any{"id": "b", "text": "x"}}}}}}
if err := runDocCoverage(t, CommentCreate, ambiguous, "--node", "n", "--content", "c", "--selection", "x", "--yes"); err == nil {
t.Fatal("ambiguous comment selection succeeded")
}
_ = findSelectionMatches(map[string]any{"id": "b", "text": "left middle right"}, "left...right")
_ = findSelectionMatches([]any{map[string]any{"id": "b", "text": "none"}}, "x")
_ = runDocCoverage(t, CommentCreate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--content", "c", "--selection", "x", "--yes")
globalReview := &docCoverageCaller{responses: map[string][]map[string]any{"list_comments": {{"comments": []any{map[string]any{"commentKey": "global", "content": "g"}}}}}}
_ = runDocCoverage(t, Review, globalReview, "--node", "n")
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--version", "3", "--yes")
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--version", "99", "--yes")
_ = runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--template-id", "t")
multipleTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "a"}, map[string]any{"templateId": "b"}}}}}}
_ = runDocCoverage(t, CreateFromTemplate, multipleTemplates, "--query", "q")
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
emptyStyle := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"ok": true}}}}
_ = runDocCoverage(t, ResourceDownload, emptyStyle, "--node", "n", "--output", "cover.png")
_ = runDocCoverage(t, ResourceDownload, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}, "--node", "n", "--output", "cover.png")
_, _ = downloadResolvedResource(nil, map[string]any{}, ".", "x")
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "bad")
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "#ABCDEG")
t.Run("preview mkdir failure", func(t *testing.T) {
testseam.Swap(t, &docMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir") })
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("preview download cleanup", func(t *testing.T) {
removed := false
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
testseam.Swap(t, &docRemoveAll, func(string) error { removed = true; return nil })
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
if !removed {
t.Fatal("preview failure did not clean temporary directory")
}
})
}
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download failed")
})
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err == nil {
t.Errorf("%s download error was ignored", item.decl.Command)
}
}
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd failed") })
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
_ = runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...)
}
}
func TestCrossPlatformCoverageDocDownloadsHaveNoOverwriteEscape(t *testing.T) {
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
t.Run(item.decl.Command, func(t *testing.T) {
for _, flag := range item.decl.Flags {
if flag.Name == "overwrite" {
t.Fatal("download shortcut still declares --overwrite")
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, item.decl, caller, append(item.args, "--overwrite")...)
if err == nil {
t.Fatal("--overwrite unexpectedly accepted")
}
if caller.calls != 0 {
t.Fatalf("rejected --overwrite performed %d MCP calls", caller.calls)
}
})
}
}
@@ -1,249 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strconv"
"strings"
"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"
)
var (
legacyVersionSave shortcut.Shortcut
legacyVersionList shortcut.Shortcut
legacyVersionRevert shortcut.Shortcut
)
func canonicalizeHistoryShortcuts() {
legacyVersionSave = VersionSave
legacyVersionList = VersionList
legacyVersionRevert = VersionRevert
VersionSave.Command = "+history-save"
VersionSave.Aliases = nil
VersionSave.Description = "手动保存当前文档版本快照"
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
VersionList.Command = "+history-list"
VersionList.Aliases = nil
VersionList.Description = "分页列出文档历史版本"
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
VersionList.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
}
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if size := rt.IntFirst("page-size", "limit"); size > 0 {
params["maxResults"] = size
}
if token := rt.StrFirst("page-token", "cursor"); token != "" {
params["nextCursor"] = token
}
return rt.CallMCP("list_doc_versions", params)
}
VersionRevert.Command = "+history-revert"
VersionRevert.Aliases = nil
VersionRevert.Description = "预检并回滚文档到指定历史版本"
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
VersionRevert.Execute = executeHistoryRevert
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
TemplateSearch.Description = "按名称检索文档模板"
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
}
func canonicalizeCommentShortcuts() {
// Preserve the historical confirmation contract for comment writes. The
// richer canonical implementations must not silently weaken that gate.
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
CommentCreate.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
}
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
CommentCreate.Validate = validateCommentCreate
CommentCreate.Execute = executeCommentCreate
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
}
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
nodeID := rt.Str("node")
target := rt.Int("version")
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
if err != nil {
return err
}
if !containsVersion(versions, target) {
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
}
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
return err
}
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return docPartialWriteError(
"doc.history_revert", "doc_history_revert_verification_failed", "verify", fmt.Sprintf("版本 %d 已回滚,但读回验证失败(nodeId=%s);不要直接重试回滚", target, nodeID), err,
map[string]any{"nodeId": nodeID, "version": target, "reverted": true},
[]map[string]any{
{"name": "preflight", "status": "success"},
{"name": "revert", "status": "success"},
{"name": "verify", "status": "failed"},
},
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func containsVersion(value any, target int) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
switch number := child.(type) {
case float64:
if int(number) == target && number == float64(target) {
return true
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
if err == nil && parsed == target {
return true
}
}
}
if containsVersion(child, target) {
return true
}
}
case []any:
for _, child := range typed {
if containsVersion(child, target) {
return true
}
}
}
return false
}
var CreateFromTemplate = shortcut.Shortcut{
Service: "doc",
Command: "+create-from-template",
Product: productDoc,
Description: "按 templateId 直达或搜索消歧后创建文档",
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
Execute: func(rt *shortcut.RuntimeContext) error {
templateID := rt.Str("template-id")
if templateID == "" {
params := map[string]any{"searchName": rt.Str("query")}
if rt.Str("source") != "" {
params["templateSource"] = rt.Str("source")
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
ids := collectTemplateIDs(found)
if len(ids) != 1 {
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
}
templateID = ids[0]
}
params := map[string]any{"templateId": templateID}
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
if value := rt.Str(flag); value != "" {
params[property] = value
}
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"executed": false, "params": params}))
}
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
},
}
func collectTemplateIDs(value any) []string {
seen := map[string]bool{}
var out []string
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
for key, child := range typed {
if strings.EqualFold(key, "templateId") || strings.EqualFold(key, "template_id") {
if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
walk(child)
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(value)
return out
}
func init() {
shortcut.Register(CreateFromTemplate)
}
@@ -1,360 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
"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/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var MediaList = shortcut.Shortcut{
Service: "doc", Command: "+media-list", Product: productDoc,
Description: "列出文档正文中的图片和附件资源",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
[]string{`dws doc +media-list --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
if err != nil {
return err
}
items := collectMediaItems(data)
return rt.Output(map[string]any{"count": len(items), "media": items})
},
}
var MediaInsert = shortcut.Shortcut{
Service: "doc", Command: "+media-insert", Product: productDoc,
Description: "上传本地图片或文件并插入文档正文",
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
}
var MediaDownload = shortcut.Shortcut{
Service: "doc", Command: "+media-download", Product: productDoc,
Description: "安全下载文档正文附件到工作目录",
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`},
Execute: executeMediaDownload,
}
var MediaPreview = shortcut.Shortcut{
Service: "doc", Command: "+media-preview", Product: productDoc,
Description: "下载正文媒体到受控临时目录并返回预览路径",
Intent: "当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-preview", "下载正文媒体到受控临时目录并返回预览路径",
"当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
[]string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
},
Tips: []string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": "managed_temp_dir"}))
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
}
dir, err := docMkdirTemp("", "dws-doc-preview-*")
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, dir, ".")
if err != nil {
_ = docRemoveAll(dir)
return err
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
},
}
var ResourceUpdate = shortcut.Shortcut{
Service: "doc", Command: "+resource-update", Product: productDoc,
Description: "从本地图片或 HTTPS URL 设置文档封面",
Intent: "当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withDryRun(docContract("+resource-update", "从本地图片或 HTTPS URL 设置文档封面",
"当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
[]string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`}), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
}
var ResourceDownload = shortcut.Shortcut{
Service: "doc", Command: "+resource-download", Product: productDoc,
Description: "读取并安全下载当前文档封面",
Intent: "当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+resource-download", "读取并安全下载当前文档封面",
"当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
[]string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`},
Execute: executeResourceDownload,
}
var ResourceDelete = shortcut.Shortcut{
Service: "doc", Command: "+resource-delete", Product: productDoc,
Description: "幂等清除文档封面",
Intent: "当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: docContract("+resource-delete", "幂等清除文档封面",
"当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
[]string{`dws doc +resource-delete --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +resource-delete --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "cover": map[string]any{"action": "clear"}}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.resource_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("update_document_style", params)
},
}
var BackgroundUpdate = shortcut.Shortcut{
Service: "doc", Command: "+background-update", Product: productDoc,
Description: "设置文档 #RRGGBB 背景纯色",
Intent: "当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+background-update", "设置文档 #RRGGBB 背景纯色",
"当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
[]string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "color", Type: shortcut.FlagString, Desc: "#RRGGBB 背景色", Required: true}},
Tips: []string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`},
Validate: func(rt *shortcut.RuntimeContext) error {
color := rt.Str("color")
if len(color) != 7 || color[0] != '#' {
return apperrors.NewValidation("--color 必须是 #RRGGBB")
}
for _, char := range color[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", char) {
return apperrors.NewValidation("--color 必须是 #RRGGBB")
}
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"color"}, Description: "#RRGGBB"}},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_document_style", map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "set", "backgroundColor": rt.Str("color")}})
},
}
var BackgroundDelete = shortcut.Shortcut{
Service: "doc", Command: "+background-delete", Product: productDoc,
Description: "清除文档背景色",
Intent: "当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: docContract("+background-delete", "清除文档背景色",
"当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
[]string{`dws doc +background-delete --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +background-delete --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "clear"}}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.background_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("update_document_style", params)
},
}
func executeMediaDownload(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(docEnvelope("doc.media_download", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": rt.Str("output")}))
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
}
cwd, err := docGetwd()
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
}
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
style, err := rt.CallMCPData(productDoc, "get_document_style", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"executed": false, "styleResolved": true, "output": rt.Str("output")}))
}
resourceURL := nestedStringDeep(style, "imageUrl", "resourceUrl", "downloadUrl", "url")
resourceID := nestedStringDeep(style, "resourceId")
data := style
if resourceURL == "" && resourceID != "" {
data, err = rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": resourceID})
if err != nil {
return err
}
} else if resourceURL != "" {
data = map[string]any{"downloadUrl": resourceURL}
}
if nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url") == "" {
return apperrors.NewAPI("当前文档没有可下载的封面,或 style 响应缺少资源地址")
}
cwd, err := docGetwd()
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
}
func downloadResolvedResource(rt *shortcut.RuntimeContext, data map[string]any, baseDir, output string) (localio.DownloadResult, error) {
resourceURL := nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url")
if resourceURL == "" {
return localio.DownloadResult{}, apperrors.NewAPI("附件下载响应缺少 downloadUrl/resourceUrl")
}
headers := map[string]string{}
if raw := nestedMap(data)["headers"]; raw != nil {
if values, ok := raw.(map[string]any); ok {
for key, value := range values {
if text, ok := value.(string); ok {
headers[key] = text
}
}
}
}
return docDownload(rt.Command().Context(), resourceURL, localio.DownloadOptions{BaseDir: baseDir, Output: output, PreferredName: nestedStringDeep(data, "fileName", "name"), Headers: headers})
}
func collectMediaItems(value any) []map[string]any {
var out []map[string]any
var walk func(any, string)
walk = func(current any, inheritedID string) {
switch typed := current.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
resourceID := fmt.Sprint(typed["resourceId"])
resourceURL := ""
for _, key := range []string{"resourceUrl", "src", "imageUrl", "downloadUrl"} {
if text, ok := typed[key].(string); ok && text != "" {
resourceURL = text
break
}
}
if (resourceID != "" && resourceID != "<nil>") || resourceURL != "" {
row := map[string]any{"blockId": blockID}
if resourceID != "" && resourceID != "<nil>" {
row["resourceId"] = resourceID
}
if resourceURL != "" {
row["resourceUrl"] = resourceURL
}
for _, key := range []string{"name", "type", "mimeType", "viewType"} {
if value, ok := typed[key]; ok {
row[key] = value
}
}
out = append(out, row)
}
for _, child := range typed {
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(value, "")
return out
}
func nestedStringDeep(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, child := range typed {
if found := nestedStringDeep(child, keys...); found != "" {
return found
}
}
case []any:
for _, child := range typed {
if found := nestedStringDeep(child, keys...); found != "" {
return found
}
}
}
return ""
}
func init() {
shortcut.Register(MediaList, MediaInsert, MediaDownload, MediaPreview, ResourceUpdate, ResourceDownload, ResourceDelete, BackgroundUpdate, BackgroundDelete)
}
-306
View File
@@ -1,306 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
"unicode/utf16"
"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"
)
var Review = shortcut.Shortcut{
Service: "doc", Command: "+review", Product: productComment,
Description: "聚合未解决评论、引用原文和块上下文",
Intent: "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+review", "聚合未解决评论、引用原文和块上下文",
"当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
[]string{`dws doc +review --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +review --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
comments, err := rt.CallMCPData(productComment, "list_comments", map[string]any{"nodeId": node, "resolveStatus": "unresolved"})
if err != nil {
return err
}
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "element"})
if err != nil {
return err
}
items := projectReviewComments(comments, blocks)
global, inline := 0, 0
for _, item := range items {
if item["blockId"] == "" {
global++
} else {
inline++
}
}
return rt.Output(map[string]any{"status": "unresolved", "counts": map[string]any{"total": len(items), "global": global, "inline": inline}, "comments": items})
},
}
var CommentUpdate = shortcut.Shortcut{
Service: "doc", Command: "+comment-update", Product: productComment,
Description: "更新指定文档评论正文和 mention",
Intent: "当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+comment-update", "更新指定文档评论正文和 mention",
"当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
[]string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("update_comment", params)
},
}
var CommentDelete = shortcut.Shortcut{
Service: "doc", Command: "+comment-delete", Product: productComment,
Description: "永久删除指定文档评论",
Intent: "当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+comment-delete", "永久删除指定文档评论",
"当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
[]string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
},
Tips: []string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key")}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.comment_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("delete_comment", params)
},
}
func validateCommentCreate(rt *shortcut.RuntimeContext) error {
hasBlock := rt.Str("block-id") != ""
hasOffsets := rt.Changed("start") || rt.Changed("end")
if hasBlock != hasOffsets || (hasOffsets && (!rt.Changed("start") || !rt.Changed("end"))) {
return apperrors.NewValidation("--block-id、--start、--end 必须一起提供")
}
if hasBlock && rt.Int("end") <= rt.Int("start") {
return apperrors.NewValidation("--end 必须大于 --start")
}
if hasBlock && rt.Str("selection") != "" {
return apperrors.NewValidation("--selection 与 block-id/start/end 高级通道不能同时提供")
}
return nil
}
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
if rt.Str("block-id") != "" {
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
if rt.Str("selected-text") != "" {
params["selectedText"] = rt.Str("selected-text")
}
return rt.CallMCP("create_inline_comment", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
if err != nil {
return err
}
matches := findSelectionMatches(blocks, selection)
if len(matches) != 1 {
candidates := make([]map[string]any, 0, len(matches))
for _, match := range matches {
candidates = append(candidates, map[string]any{"blockId": match.blockID, "excerpt": match.text})
}
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return rt.CallMCP("create_inline_comment", params)
}
return rt.CallMCP("create_comment", params)
}
type selectionMatch struct {
blockID string
text, selected string
start, end int
}
func findSelectionMatches(value any, selection string) []selectionMatch {
if selection == "" {
return nil
}
var prefix, suffix string
omitted := false
if parts := strings.SplitN(selection, "...", 2); len(parts) == 2 {
prefix, suffix = parts[0], parts[1]
omitted = true
}
var out []selectionMatch
appendMatch := func(blockID, text string, startByte, endByte int) {
out = append(out, selectionMatch{
blockID: blockID, text: text, selected: text[startByte:endByte],
start: utf16Length(text[:startByte]), end: utf16Length(text[:endByte]),
})
}
var walk func(any, string)
walk = func(current any, inheritedID string) {
switch typed := current.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
if !omitted {
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], selection)
if index < 0 {
break
}
startByte := searchStart + index
endByte := startByte + len(selection)
appendMatch(blockID, text, startByte, endByte)
searchStart = startByte + 1
}
} else {
prefixStarts := []int{0}
if prefix != "" {
prefixStarts = nil
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], prefix)
if index < 0 {
break
}
startByte := searchStart + index
prefixStarts = append(prefixStarts, startByte)
searchStart = startByte + 1
}
}
for _, startByte := range prefixStarts {
suffixSearch := startByte + len(prefix)
if suffix == "" {
if startByte < len(text) {
appendMatch(blockID, text, startByte, len(text))
}
continue
}
for suffixSearch <= len(text) {
index := strings.Index(text[suffixSearch:], suffix)
if index < 0 {
break
}
suffixStart := suffixSearch + index
appendMatch(blockID, text, startByte, suffixStart+len(suffix))
suffixSearch = suffixStart + 1
}
}
}
}
for _, child := range typed {
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(value, "")
return out
}
func utf16Length(value string) int {
return len(utf16.Encode([]rune(value)))
}
func projectReviewComments(comments, blocks map[string]any) []map[string]any {
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
var out []map[string]any
var walk func(any)
walk = func(value any) {
switch typed := value.(type) {
case map[string]any:
key := firstString(typed, "commentKey", "commentId")
if key != "" {
blockID := firstString(typed, "blockId")
selectedText := firstString(typed, "selectedText", "quote")
// list_comments currently omits blockId for inline comments but
// includes isGlobal=false plus the selected quote. Resolve the
// block deterministically when that quote is unique.
if blockID == "" && selectedText != "" {
if matches := findSelectionMatches(blocks, selectedText); len(matches) == 1 {
blockID = matches[0].blockID
}
}
out = append(out, map[string]any{
"commentKey": key,
"content": firstString(typed, "content", "text"),
"selectedText": selectedText,
"blockId": blockID,
"context": blockText[blockID],
"replies": typed["replies"],
})
return
}
for _, child := range typed {
walk(child)
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(comments)
return out
}
func collectBlockText(value any, inheritedID string, out map[string]string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
out[blockID] = text
}
for _, child := range typed {
collectBlockText(child, blockID, out)
}
case []any:
for _, child := range typed {
collectBlockText(child, inheritedID, out)
}
}
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := values[key].(string); ok {
return text
}
}
return ""
}
func init() {
shortcut.Register(Review, CommentUpdate, CommentDelete)
}
@@ -92,7 +92,6 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"chat\u0000+chat-dismiss": {},
"chat\u0000+chat-get-by-id": {},
"chat\u0000+chat-invite-url": {},
"chat\u0000+chat-list": {},
"chat\u0000+chat-list-all": {},
"chat\u0000+chat-list-join-requests": {},
"chat\u0000+chat-list-mine": {},
@@ -209,48 +208,20 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"ding\u0000+recall-personal": {},
"ding\u0000+receiver-status": {},
"ding\u0000+send-personal": {},
"doc\u0000+access-change": {},
"doc\u0000+access-grant": {},
"doc\u0000+access-revoke": {},
"doc\u0000+background-delete": {},
"doc\u0000+background-update": {},
"doc\u0000+checkpoint-update": {},
"doc\u0000+comment-create": {},
"doc\u0000+comment-delete": {},
"doc\u0000+comment-list": {},
"doc\u0000+comment-reply": {},
"doc\u0000+comment-update": {},
"doc\u0000+copy": {},
"doc\u0000+create": {},
"doc\u0000+create-from-template": {},
"doc\u0000+doc-append": {},
"doc\u0000+export": {},
"doc\u0000+export-get": {},
"doc\u0000+export-submit": {},
"doc\u0000+fetch": {},
"doc\u0000+find-doc": {},
"doc\u0000+grant-and-share": {},
"doc\u0000+history-list": {},
"doc\u0000+history-revert": {},
"doc\u0000+history-save": {},
"doc\u0000+import": {},
"doc\u0000+inspect": {},
"doc\u0000+list": {},
"doc\u0000+media-download": {},
"doc\u0000+media-insert": {},
"doc\u0000+media-list": {},
"doc\u0000+media-preview": {},
"doc\u0000+move": {},
"doc\u0000+resource-delete": {},
"doc\u0000+resource-download": {},
"doc\u0000+resource-update": {},
"doc\u0000+review": {},
"doc\u0000+search": {},
"doc\u0000+share": {},
"doc\u0000+share-doc": {},
"doc\u0000+template-list": {},
"doc\u0000+template-search": {},
"doc\u0000+update": {},
"doc\u0000+version-list": {},
"doc\u0000+version-revert": {},
"doc\u0000+version-save": {},
+5 -28
View File
@@ -13,9 +13,6 @@ import (
//go:embed semantic_catalog.json
var semanticCatalogJSON []byte
//go:embed semantic_catalog_doc.json
var docSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -33,34 +30,17 @@ type semanticCatalogRecord struct {
Reviewed bool `json:"reviewed"`
}
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
semanticCatalogJSON,
docSemanticCatalogJSON,
)
var reviewedSemanticCatalog = mustLoadSemanticCatalog()
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
out := make(map[string]semanticCatalogRecord)
for _, raw := range sources {
loadSemanticCatalog(raw, out)
}
return out
}
// mustLoadSemanticCatalog is retained for focused validation tests of the
// legacy single-source loader. Production loads every reviewed product source
// through mustLoadSemanticCatalogs above.
func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
return mustLoadSemanticCatalogs(semanticCatalogJSON)
}
func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
var source semanticCatalogFile
if err := json.Unmarshal(raw, &source); err != nil {
if err := json.Unmarshal(semanticCatalogJSON, &source); err != nil {
panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err))
}
if source.Version != 1 || strings.TrimSpace(source.Service) == "" {
panic("invalid shortcut semantic catalog header")
}
out := make(map[string]semanticCatalogRecord, len(source.Shortcuts))
for command, record := range source.Shortcuts {
if !strings.HasPrefix(command, "+") {
panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command))
@@ -96,12 +76,9 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
command, record.Availability))
}
key := publicCatalogKey(source.Service, command)
if _, exists := out[key]; exists {
panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command))
}
out[key] = record
out[publicCatalogKey(source.Service, command)] = record
}
return out
}
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
@@ -137,29 +137,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
}
}
func TestCrossPlatformCoverageSemanticCatalogRejectsCrossSourceDuplicates(t *testing.T) {
valid := []byte(`{
"version": 1,
"service": "duplicate-test",
"default_availability": "available",
"shortcuts": {
"+same": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": true,
"reviewed": true
}
}
}`)
defer func() {
if recover() == nil {
t.Fatal("duplicate semantic records did not panic")
}
}()
_ = mustLoadSemanticCatalogs(valid, valid)
}
func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) {
if !InPublicCatalog("chat", "+messages-send") {
t.Fatal("reviewed public semantic shortcut is missing")
@@ -1,56 +0,0 @@
{
"version": 1,
"service": "doc",
"default_availability": "available",
"shortcuts": {
"+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。","risk":"read","public":true,"reviewed":true},
"+create": {"disposition":"semantic_adapter","semantic_delta":"统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。","risk":"write","public":true,"reviewed":true},
"+fetch": {"disposition":"semantic_adapter","semantic_delta":"统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。","risk":"read","public":true,"reviewed":true},
"+inspect": {"disposition":"primary_smart","semantic_delta":"聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。","risk":"read","public":true,"reviewed":true},
"+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},
"+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},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
"+media-download": {"disposition":"primary_smart","semantic_delta":"解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。","risk":"read","public":true,"reviewed":true},
"+media-preview": {"disposition":"primary_smart","semantic_delta":"将正文媒体下载到受控临时目录并返回本地预览 artifact。","risk":"read","public":true,"reviewed":true},
"+resource-update": {"disposition":"primary_smart","semantic_delta":"支持本地图片或 HTTPS 图片转存后设置文档封面。","risk":"write","public":true,"reviewed":true},
"+resource-download": {"disposition":"primary_smart","semantic_delta":"读取当前文档封面配置并安全下载资源到本地。","risk":"read","public":true,"reviewed":true},
"+resource-delete": {"disposition":"semantic_adapter","semantic_delta":"以幂等 clear 语义移除当前文档封面。","risk":"high-risk-write","public":true,"reviewed":true},
"+background-update": {"disposition":"semantic_adapter","semantic_delta":"校验并设置 #RRGGBB 文档背景纯色。","risk":"write","public":true,"reviewed":true},
"+background-delete": {"disposition":"semantic_adapter","semantic_delta":"以 clear 语义移除文档背景色。","risk":"write","public":true,"reviewed":true},
"+comment-list": {"disposition":"semantic_adapter","semantic_delta":"统一评论类型、解决状态与分页过滤。","risk":"read","public":true,"reviewed":true},
"+review": {"disposition":"primary_smart","semantic_delta":"聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。","risk":"read","public":true,"reviewed":true},
"+comment-create": {"disposition":"primary_smart","semantic_delta":"无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。","risk":"write","public":true,"reviewed":true},
"+comment-reply": {"disposition":"semantic_adapter","semantic_delta":"统一评论回复、表情回复和 mention 参数。","risk":"write","public":true,"reviewed":true},
"+comment-update": {"disposition":"semantic_adapter","semantic_delta":"更新指定评论正文与 mention。","risk":"write","public":true,"reviewed":true},
"+comment-delete": {"disposition":"semantic_adapter","semantic_delta":"永久删除指定评论,并由静态安全契约强制确认。","risk":"high-risk-write","public":true,"reviewed":true},
"+access-grant": {"disposition":"primary_smart","semantic_delta":"在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。","risk":"write","public":true,"reviewed":true},
"+access-change": {"disposition":"primary_smart","semantic_delta":"读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。","risk":"write","public":true,"reviewed":true},
"+access-revoke": {"disposition":"primary_smart","semantic_delta":"预检目标协作者权限后移除并输出逐项结果。","risk":"high-risk-write","public":true,"reviewed":true},
"+share": {"disposition":"primary_smart","semantic_delta":"按姓名解析唯一用户后发送文档链接,不改变文档权限。","risk":"write","public":true,"reviewed":true},
"+grant-and-share": {"disposition":"primary_smart","semantic_delta":"先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。","risk":"write","public":true,"reviewed":true},
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
"+copy": {"disposition":"alias_internal","semantic_delta":"旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。","risk":"write","primary":"drive copy","public":true,"reviewed":true},
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
}
}
+13 -73
View File
@@ -24,7 +24,6 @@ import (
"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"
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
@@ -87,37 +86,18 @@ var AtMe = shortcut.Shortcut{
},
},
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
{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: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{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"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
`dws chat +at-me --group "项目群"`,
},
Validate: validateAtMe,
Validate: chatshortcut.ValidateMessageResourceDownload,
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
}
// 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")
@@ -131,75 +111,35 @@ var AtMe = shortcut.Shortcut{
// 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{
data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
}
if groupID != "" {
params["openConversationId"] = groupID
}
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.
// Step 3 — project matched messages; fall back to the raw payload when we
// cannot locate a recognisable message list.
items := atMeMessageItems(data)
if len(items) == 0 {
return rt.Output(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)
payload := map[string]any{"messages": results}
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
}
return rt.Output(payload)
},
}
func validateAtMe(rt *shortcut.RuntimeContext) error {
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
return err
}
days := rt.Int("days")
if days < 1 || days > 3650 {
return localChatOptionError("invalid_lookback_window", "+at-me 的 --days 必须在 1-3650 之间", "--days")
}
if rt.Int("limit") <= 0 {
return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit")
}
return nil
}
// 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.
func atMeCompatibilityItems(messages []map[string]any) []map[string]any {
items := make([]map[string]any, 0, len(messages))
for _, message := range messages {
item := make(map[string]any, len(message))
for key, value := range message {
item[key] = value
}
conversation := map[string]any{}
if name := atMeString(message["conversation"]); name != "" {
conversation["name"] = name
}
if id := atMeString(message["conversationId"]); id != "" {
conversation["openConversationId"] = id
}
item["conversation"] = conversation
items = append(items, item)
}
return items
}
// atMeMessageItems locates the message list inside a search_at_me_message
// response, probing common container keys at the top level and nested under
// "result". Returns nil when no list is found.
@@ -20,7 +20,7 @@ import (
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestCrossPlatformCoverageAtMeProject(t *testing.T) {
func TestAtMeProject(t *testing.T) {
// nested sender object + plain text
row := atMeProject(map[string]any{
"sender": map[string]any{"name": "念晨"},
@@ -87,7 +87,7 @@ func TestCrossPlatformCoverageAtMeProject(t *testing.T) {
}
}
func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
func TestSearchMsgProject(t *testing.T) {
// nested sender + plain text + messageId
row := searchMsgProject(map[string]any{
"sender": map[string]any{"nick": "千启"},
@@ -129,7 +129,7 @@ func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
// TestSenderHelpers exercises the atMe/searchMsg sender key families directly:
// a senderName-family key (first probe loop), a flat string under "sender"
// (second loop), and the "null" sentinel normalisation.
func TestCrossPlatformCoverageSenderHelpers(t *testing.T) {
func TestSenderHelpers(t *testing.T) {
cases := []struct {
fn func(map[string]any) any
name string

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