Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5d7ee21e | ||
|
|
2f3797c1f6 | ||
|
|
990c85d36b | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
4bcf71fb9e | ||
|
|
545ee17316 | ||
|
|
0c62938f74 | ||
|
|
45b43e52bb | ||
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
ddd5f15b91 | ||
|
|
db50be868b | ||
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
f3a95d34a3 | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
e2c15fe9c8 | ||
|
|
9fdf0d2cb3 | ||
|
|
b6325a4f8a | ||
|
|
fdd9e189d6 | ||
|
|
eebdf52da9 | ||
|
|
9eaee76a51 | ||
|
|
2588c711a7 | ||
|
|
01782cd9d7 | ||
|
|
ad4ed41559 | ||
|
|
86f9054d5b | ||
|
|
d7c28bcfef | ||
|
|
287b079c18 | ||
|
|
c4d5139a50 | ||
|
|
867f20abec | ||
|
|
7c07b29de5 | ||
|
|
6ee0df8a9c | ||
|
|
33ceab6000 | ||
|
|
26b06fe0ff | ||
|
|
50f8ade1d7 | ||
|
|
b87cad1eb5 | ||
|
|
0f2eec145e | ||
|
|
7a5582f4f9 | ||
|
|
f050fbdebc | ||
|
|
e27dc9fe53 | ||
|
|
85587b9b62 | ||
|
|
10d93f310e | ||
|
|
99478c0060 | ||
|
|
64c2e8544c | ||
|
|
fc31fddd73 | ||
|
|
4298d0833b | ||
|
|
ee943d9b3f | ||
|
|
7e0957d9e8 | ||
|
|
a3c85a01a8 | ||
|
|
81f67c8d7b | ||
|
|
30314311e4 | ||
|
|
2a8c6c87cb | ||
|
|
017258e5b4 |
@@ -817,6 +817,7 @@ 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
|
||||
|
||||
@@ -6,8 +6,73 @@ 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.
|
||||
|
||||
@@ -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.56-beta.4"
|
||||
version "1.0.57-beta.2"
|
||||
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.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
|
||||
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -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 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 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
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -33,6 +33,7 @@ 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"
|
||||
@@ -87,6 +88,7 @@ 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
|
||||
@@ -126,6 +128,9 @@ 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-07-29T00:06:19.285348",
|
||||
"count": 265,
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -485,7 +485,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -658,6 +658,16 @@
|
||||
"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",
|
||||
@@ -715,7 +725,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
||||
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1215,7 +1225,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1295,7 +1305,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
||||
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1315,7 +1325,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1344,8 +1354,8 @@
|
||||
"command": "+messages-send-card",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1425,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1435,7 +1445,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1708,123 +1718,454 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+comment-create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-reply",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "更新指定评论正文与 mention。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+doc-append",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-submit",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+find-doc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"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",
|
||||
"service": "doc",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share-doc",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
|
||||
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
markdown := &cobra.Command{Use: "markdown"}
|
||||
@@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
want := []string{"+listen-im", "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 TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
@@ -29,6 +29,7 @@ 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"},
|
||||
@@ -37,6 +38,7 @@ 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"},
|
||||
@@ -119,6 +121,7 @@ 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"},
|
||||
@@ -129,6 +132,7 @@ 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"},
|
||||
@@ -169,7 +173,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -220,6 +224,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
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)
|
||||
}
|
||||
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
test := test
|
||||
@@ -292,6 +297,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -302,6 +308,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// +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 {
|
||||
|
||||
@@ -72,6 +72,39 @@ 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 {
|
||||
|
||||
+41
-14
@@ -413,6 +413,33 @@ 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
|
||||
}
|
||||
@@ -678,7 +705,6 @@ 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.
|
||||
@@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
mcpErr := newServerFailureAPIError(
|
||||
extractMCPErrorMessage(callResult),
|
||||
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),
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
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)
|
||||
@@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
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),
|
||||
classifiedErr := newServerFailureAPIError(
|
||||
bizErr,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
return executor.Result{}, classifiedErr
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 266
|
||||
publicShortcutCount = 294
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 216
|
||||
schemaPublishedShortcutCount = 295
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 215
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
)
|
||||
|
||||
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)), 129; got != want {
|
||||
if got, want := int(product["count"].(float64)), 180; 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 != 47 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
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"
|
||||
}
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -234,6 +226,15 @@ 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 {
|
||||
@@ -299,6 +300,13 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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,23 +117,14 @@ 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) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
profilectx.Set(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
return profilectx.Get()
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
|
||||
@@ -158,6 +158,12 @@ 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{
|
||||
@@ -336,6 +342,12 @@ 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{
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
"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."},
|
||||
@@ -255,7 +257,9 @@
|
||||
{"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 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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,12 @@ 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") {
|
||||
|
||||
@@ -30,38 +30,29 @@ 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 string
|
||||
type AgentExampleMode = contract.ExampleDispositionMode
|
||||
|
||||
const (
|
||||
AgentExampleModeContract AgentExampleMode = "contract"
|
||||
AgentExampleModeDryRun AgentExampleMode = "dry_run"
|
||||
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
|
||||
AgentExampleModeContract = contract.ExampleDispositionModeContract
|
||||
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
|
||||
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
|
||||
)
|
||||
|
||||
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
|
||||
// exceptions to an explicit dry-run capability.
|
||||
type AgentExampleReasonCode string
|
||||
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
|
||||
|
||||
const (
|
||||
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
|
||||
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
|
||||
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
|
||||
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
|
||||
)
|
||||
|
||||
// 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 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"`
|
||||
}
|
||||
// Dispositions are authored on the owning ContractFinal Selection.
|
||||
type AgentExampleDisposition = contract.ExampleDisposition
|
||||
|
||||
// AgentExampleExecution is one resolved example and its effective test mode.
|
||||
type AgentExampleExecution struct {
|
||||
|
||||
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
|
||||
out.UseWhen = selection.UseWhen
|
||||
out.AvoidWhen = selection.AvoidWhen
|
||||
out.Examples = selection.Examples
|
||||
out.ExampleDispositions = selection.ExampleDispositions
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -171,62 +171,4 @@ 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,16 +123,11 @@ 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"}
|
||||
})
|
||||
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,
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("plan error = %v", err)
|
||||
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
|
||||
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,
|
||||
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,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
_, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
|
||||
t.Fatalf("error = %v", err)
|
||||
|
||||
@@ -332,6 +332,10 @@ 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)
|
||||
|
||||
|
||||
@@ -200,6 +200,10 @@ 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.
|
||||
@@ -219,10 +223,54 @@ 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,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
|
||||
exampleIndex := 0
|
||||
normalized := (SelectionSpec{
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
ExampleDispositions: []ExampleDisposition{{
|
||||
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
|
||||
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
|
||||
}},
|
||||
SourceRefs: []string{"b", "a", "b"},
|
||||
}).Normalized()
|
||||
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
|
||||
@@ -86,6 +91,16 @@ 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)
|
||||
}
|
||||
|
||||
+15
-10
@@ -103,11 +103,12 @@ 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)
|
||||
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)
|
||||
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
|
||||
|
||||
// Required, when true, validates a non-empty effective value in RunE. Plain
|
||||
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
|
||||
@@ -551,7 +552,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))
|
||||
}
|
||||
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
|
||||
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, 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 {
|
||||
@@ -572,6 +573,10 @@ 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
|
||||
@@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
}
|
||||
defInt = v
|
||||
}
|
||||
cmd.Flags().Int(name, defInt, usage)
|
||||
cmd.Flags().IntP(name, shorthand, defInt, usage)
|
||||
case KindBool:
|
||||
defBool := false
|
||||
if def != "" {
|
||||
@@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
|
||||
}
|
||||
}
|
||||
cmd.Flags().Bool(name, defBool, usage)
|
||||
cmd.Flags().BoolP(name, shorthand, defBool, usage)
|
||||
case KindStringSlice:
|
||||
var defaults []string
|
||||
if value := strings.TrimSpace(def); value != "" {
|
||||
defaults = strings.Split(value, ",")
|
||||
}
|
||||
cmd.Flags().StringSlice(name, defaults, usage)
|
||||
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
|
||||
default:
|
||||
cmd.Flags().String(name, def, usage)
|
||||
cmd.Flags().StringP(name, shorthand, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
|
||||
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
cmd := newTestCommand()
|
||||
RegisterFlags(cmd, []FlagSpec{
|
||||
{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: "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: "req", Usage: "R", MarkRequired: true},
|
||||
{Name: "hidden", Usage: "H", Hidden: true},
|
||||
})
|
||||
@@ -78,6 +78,11 @@ 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 {
|
||||
|
||||
@@ -46,6 +46,9 @@ type Error struct {
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Origin string
|
||||
FailureStage string
|
||||
ExecutionStarted *bool
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
@@ -55,6 +58,7 @@ 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:"-"`
|
||||
@@ -107,6 +111,32 @@ 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) {
|
||||
@@ -186,6 +216,21 @@ 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) {
|
||||
@@ -295,6 +340,15 @@ 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
|
||||
}
|
||||
@@ -316,6 +370,9 @@ 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
|
||||
}
|
||||
@@ -447,6 +504,15 @@ 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)))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExitCodeByCategory(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
@@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON(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)
|
||||
}
|
||||
@@ -67,6 +74,11 @@ func TestPrintJSON(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)
|
||||
}
|
||||
@@ -76,6 +88,9 @@ func TestPrintJSON(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) {
|
||||
@@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -201,6 +216,9 @@ func TestPrintHuman(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"),
|
||||
@@ -228,9 +246,19 @@ func TestPrintHuman(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 TestPrintHuman_NormalMode(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -78,6 +79,8 @@ 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) {
|
||||
@@ -90,6 +93,9 @@ 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,3 +28,13 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
+213
-74
@@ -18,6 +18,7 @@ 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"
|
||||
|
||||
@@ -53,6 +54,43 @@ 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) {
|
||||
@@ -78,6 +116,61 @@ 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
|
||||
@@ -311,6 +404,50 @@ 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)
|
||||
@@ -1277,34 +1414,23 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -1955,29 +2081,15 @@ 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,
|
||||
}
|
||||
if atAll {
|
||||
newParams["atAll"] = true
|
||||
}
|
||||
if len(atOpenIds) > 0 {
|
||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
||||
}
|
||||
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams["content"] = string(contentJSON)
|
||||
if msgUuid != "" {
|
||||
newParams["uuid"] = msgUuid
|
||||
}
|
||||
@@ -3353,15 +3465,6 @@ 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 开始")
|
||||
@@ -3387,7 +3490,7 @@ func newChatCommand() *cobra.Command {
|
||||
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
|
||||
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
|
||||
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
|
||||
|
||||
// message 子命令 flags
|
||||
@@ -3940,6 +4043,7 @@ 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 {
|
||||
@@ -3947,6 +4051,14 @@ 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,
|
||||
})
|
||||
@@ -5294,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageReplyCmd := &cobra.Command{
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||
|
||||
如何获取 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 "收到,马上处理"`,
|
||||
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>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
return err
|
||||
@@ -5313,13 +5426,6 @@ 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 {
|
||||
@@ -5328,9 +5434,25 @@ 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
|
||||
}
|
||||
@@ -5364,6 +5486,8 @@ 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"},
|
||||
},
|
||||
},
|
||||
@@ -5378,6 +5502,8 @@ 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: 转发单条消息 ────────────────────────
|
||||
@@ -6261,8 +6387,12 @@ 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": mustGetFlag(cmd, "group"),
|
||||
"openConversationId": groupID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -6296,7 +6426,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
},
|
||||
})
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
|
||||
_ = chatGroupBotsCmd.MarkFlagRequired("group")
|
||||
|
||||
chatGroupMembersRemoveBotCmd := &cobra.Command{
|
||||
@@ -6432,7 +6562,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
Use: "dismiss",
|
||||
Short: "解散群聊",
|
||||
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
|
||||
Example: ` dws chat group dismiss --group <openConversationId> --yes
|
||||
Example: ` dws chat group dismiss --group <openConversationId>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -7276,7 +7406,7 @@ status 可选值:
|
||||
chatClearMessagesCmd := &cobra.Command{
|
||||
Use: "clear-messages",
|
||||
Short: "清空当前用户指定会话的聊天记录",
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
@@ -7288,6 +7418,14 @@ 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,
|
||||
})
|
||||
@@ -7763,8 +7901,8 @@ status 可选值:
|
||||
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
|
||||
|
||||
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -8080,10 +8218,11 @@ 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)
|
||||
|
||||
// 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>"))
|
||||
// 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>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -43,6 +43,75 @@ 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"}
|
||||
@@ -112,6 +181,7 @@ 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,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -285,6 +286,150 @@ 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")
|
||||
|
||||
@@ -310,6 +310,44 @@ 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",
|
||||
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
通讯录功能:
|
||||
- contact user get-self/search/search-mobile/get: 通讯录用户查询
|
||||
- contact user invite/update/update-self: 邀请与更新员工
|
||||
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
|
||||
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
|
||||
- contact relation list-my-followings: 特别关注人查询
|
||||
|
||||
@@ -414,6 +452,7 @@ 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`,
|
||||
@@ -1355,6 +1394,40 @@ 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", "", "搜索关键词 (必填)")
|
||||
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
|
||||
_ = contactUserGetCmd.Flags().MarkHidden("userid")
|
||||
userCmd.AddCommand(
|
||||
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserUpdateOwnnessCmd, // 更新用户个人状态
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
)
|
||||
|
||||
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
|
||||
@@ -48,6 +48,7 @@ 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 {
|
||||
@@ -98,6 +99,18 @@ 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"},
|
||||
@@ -139,6 +152,7 @@ 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 {
|
||||
@@ -174,6 +188,10 @@ 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"}, "至少需要一个修改项"},
|
||||
|
||||
+53
-3
@@ -816,6 +816,8 @@ 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 查询导出任务结果 (手动兜底)
|
||||
@@ -2525,6 +2527,54 @@ 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: "上传附件并插入文档",
|
||||
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
|
||||
|
||||
// media 子命令的 --node 隐藏别名
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
|
||||
for _, c := range mediaNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||||
|
||||
// ── comment (文档评论) ──────────────────────────────────
|
||||
commentCmd := &cobra.Command{
|
||||
@@ -4227,7 +4277,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())
|
||||
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())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// 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
|
||||
}
|
||||
@@ -66,6 +66,8 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
driveRangeClient = &http.Client{
|
||||
// 通过结构化 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) {
|
||||
// 每次请求加小延迟,让总处理时间接近 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
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 多次尝试以确保覆盖(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()
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ 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,
|
||||
|
||||
@@ -190,6 +190,9 @@ 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_",
|
||||
} {
|
||||
|
||||
@@ -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",
|
||||
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
|
||||
} {
|
||||
if !seenProducts[want] {
|
||||
t.Errorf("public product %q was not registered", want)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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,6 +271,10 @@ 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",
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ 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,
|
||||
@@ -47,11 +57,12 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
Flags: fromShortcutFlags(s.Flags),
|
||||
Constraints: fromShortcutConstraints(s.Constraints),
|
||||
Safety: safety,
|
||||
Contract: s.Contract,
|
||||
Contract: declaredContract,
|
||||
// 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.
|
||||
@@ -65,6 +76,57 @@ 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) != "" ||
|
||||
@@ -72,6 +134,16 @@ 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 ""
|
||||
@@ -133,6 +205,7 @@ 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,
|
||||
@@ -142,6 +215,7 @@ 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
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -33,7 +34,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
||||
Hidden: true,
|
||||
Tips: []string{"dws chat +demo --name a"},
|
||||
Flags: []Flag{
|
||||
{Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
|
||||
{Name: "name", Shorthand: "n", 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: "列表"},
|
||||
@@ -65,6 +66,13 @@ 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")
|
||||
}
|
||||
@@ -85,7 +93,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
name := cs.Flags[0]
|
||||
if name.Name != "name" || !name.Required || name.Default != "d" ||
|
||||
if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" ||
|
||||
!name.Hidden || name.ValidationMode != corecmd.ValidationShortcut ||
|
||||
name.RequiredError != "缺少必填参数 --name:名称" ||
|
||||
strings.Join(name.Enum, ",") != "a,b" {
|
||||
@@ -120,6 +128,75 @@ 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.
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// limitations under the License.
|
||||
|
||||
// Package builtin aggregates all built-in shortcut service packages via blank
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
||||
func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
||||
mounted := map[string]*cobra.Command{}
|
||||
for _, service := range builtin.BaseCommands() {
|
||||
for _, command := range service.Commands() {
|
||||
@@ -83,6 +83,18 @@ func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.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" {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -28,14 +31,15 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
|
||||
})
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"tool": tool,
|
||||
"actionCount": len(actions),
|
||||
"failedCount": 0,
|
||||
"actions": actions,
|
||||
"requestedCount": len(items),
|
||||
"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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,13 +60,33 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
|
||||
}
|
||||
succeeded = append(succeeded, entry)
|
||||
}
|
||||
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,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
BotSearch,
|
||||
BotFind,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -88,8 +89,8 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
|
||||
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -164,7 +165,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{
|
||||
Command: "+conversation-mute-at-all",
|
||||
Product: "im",
|
||||
Description: "关闭/开启 @所有人消息提醒",
|
||||
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
|
||||
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -185,7 +186,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{
|
||||
Command: "+conversation-mute-red-envelope",
|
||||
Product: "im",
|
||||
Description: "关闭/开启红包消息提醒",
|
||||
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
|
||||
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -284,8 +285,8 @@ var ConversationList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-list",
|
||||
Product: "im",
|
||||
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
|
||||
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
|
||||
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
|
||||
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -299,47 +300,143 @@ 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{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"},
|
||||
AgentSummary: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
|
||||
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +conversation-list --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 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 {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
cursor := int64(rt.Int("cursor"))
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
if rt.Int("cursor") > 0 {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
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.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
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)})
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if err != nil {
|
||||
return err
|
||||
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,
|
||||
}
|
||||
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
|
||||
@@ -379,12 +476,12 @@ func conversationListResolveList(data map[string]any) []any {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(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 arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,6 +489,19 @@ 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 {
|
||||
@@ -576,12 +686,12 @@ func conversationListTopResolveList(data map[string]any) []any {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(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 arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1070,7 +1180,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ConversationInfo,
|
||||
ConversationSetTop,
|
||||
ConversationMute,
|
||||
@@ -1091,5 +1201,5 @@ func init() {
|
||||
CategoryRename,
|
||||
CategoryAddConversation,
|
||||
CategoryRemoveConversation,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": map[string]any{
|
||||
"items": []any{
|
||||
@@ -75,7 +77,7 @@ func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListTopRejectsInvalidType(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -87,3 +89,82 @@ func TestConversationListTopRejectsInvalidType(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,19 @@ 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",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Service: "chat",
|
||||
Command: "+chat-search",
|
||||
Aliases: []string{"+chat-group-search", "+search-group"},
|
||||
SinglePositionalAliasFor: "query",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -147,7 +150,7 @@ var ChatInviteURL = shortcut.Shortcut{
|
||||
Command: "+chat-invite-url",
|
||||
Product: "im",
|
||||
Description: "获取群邀请链接",
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -169,20 +172,32 @@ var ChatInviteURL = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取群邀请链接",
|
||||
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。"},
|
||||
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 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", Required: true},
|
||||
{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: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
|
||||
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 "项目群"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID, err := resolveStableOrNamedChat(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
"openConversationId": groupID,
|
||||
"cid": groupID,
|
||||
}
|
||||
if rt.Changed("expires-seconds") {
|
||||
params["expiresSeconds"] = rt.Int("expires-seconds")
|
||||
@@ -777,7 +792,7 @@ var ChatBots = shortcut.Shortcut{
|
||||
Command: "+chat-bots",
|
||||
Product: "bot",
|
||||
Description: "查看群内所有机器人",
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -799,17 +814,29 @@ var ChatBots = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看群内所有机器人",
|
||||
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。"},
|
||||
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +chat-bots --group <openConversationId>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{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 "项目群"`,
|
||||
},
|
||||
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
|
||||
groupID, err := resolveStableOrNamedChat(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": groupID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -818,6 +845,21 @@ 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
|
||||
@@ -1461,7 +1503,7 @@ var ChatRoleQueryUser = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ChatSearch,
|
||||
ChatMembersGet,
|
||||
ChatTransferOwner,
|
||||
@@ -1491,5 +1533,5 @@ func init() {
|
||||
ChatRoleSetUser,
|
||||
ChatRoleRemoveUser,
|
||||
ChatRoleQueryUser,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -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: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。",
|
||||
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
Intent: "只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用",
|
||||
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{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"},
|
||||
AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||||
},
|
||||
@@ -172,17 +172,56 @@ var MessagesRecall = shortcut.Shortcut{
|
||||
Command: "+messages-recall",
|
||||
Product: "im",
|
||||
Description: "撤回当前用户发送的消息",
|
||||
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
|
||||
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息。推荐同时传会话 openConversationId 和消息 openMessageId;若只传一个消息 ID,CLI 会先只读查询消息详情并补齐会话 ID。兼容 --message-id/--message-ids 的单值写法。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||||
{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 仅接受单值"},
|
||||
},
|
||||
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": rt.Str("conversation-id"),
|
||||
"openMessageId": rt.Str("msg-id"),
|
||||
"openConversationId": conversationID,
|
||||
"openMessageId": messageID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -314,46 +353,22 @@ func listMessageProjectOne(m map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
|
||||
row["quotedMessage"] = quoted
|
||||
// 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 resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
|
||||
row["resourceRefs"] = resources
|
||||
}
|
||||
projectForwarded := func(item map[string]any) map[string]any {
|
||||
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
|
||||
return listMessageProjectOneWithReactions(item, includeReactions)
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||||
}); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
@@ -665,20 +680,35 @@ 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") {
|
||||
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
|
||||
AttachMessageResourceDownloads(payload, DownloadMessageResources(rt, rawMessages, ""))
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
@@ -690,7 +720,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: "允许覆盖工作目录内已存在的本地输出文件(默认拒绝)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +932,47 @@ 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
|
||||
@@ -1168,7 +1239,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 完成,避免卡片停留在加载中。",
|
||||
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。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -1190,7 +1261,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 完成,避免卡片停留在加载中。"},
|
||||
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。"},
|
||||
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
|
||||
Examples: []string{
|
||||
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
|
||||
@@ -1221,7 +1292,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"); status < 1 || status > 5 {
|
||||
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
if rt.Changed("flow-status") && rt.Str("content") == "" {
|
||||
@@ -1253,11 +1324,12 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
status := rt.Int("flow-status")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"actionCount": 2,
|
||||
"failedCount": 0,
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"actionCount": 2,
|
||||
"failedCount": 0,
|
||||
"actions": []map[string]any{
|
||||
{
|
||||
"tool": "create_and_send_card",
|
||||
@@ -1291,11 +1363,12 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"ok": true,
|
||||
"bizId": bizID,
|
||||
"flowStatus": status,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"ok": true,
|
||||
"bizId": bizID,
|
||||
"flowStatus": status,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1362,7 +1435,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
Command: "+messages-update-card",
|
||||
Product: "im",
|
||||
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||||
Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。",
|
||||
Intent: "当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -1384,7 +1457,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||||
UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"},
|
||||
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
|
||||
},
|
||||
@@ -1392,9 +1465,18 @@ 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错误", 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 之间"},
|
||||
},
|
||||
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"),
|
||||
@@ -1735,7 +1817,7 @@ var MessagesUnsetTop = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
MessagesSendByBot,
|
||||
MessagesBatchSendByBot,
|
||||
MessagesSendByWebhook,
|
||||
@@ -1764,5 +1846,5 @@ func init() {
|
||||
MessagesListPin,
|
||||
MessagesSetTop,
|
||||
MessagesUnsetTop,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
|
||||
|
||||
func TestListMessageProjectOne(t *testing.T) {
|
||||
func TestCrossPlatformCoverageListMessageProjectOne(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,7 +75,35 @@ func TestListMessageProjectOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPinProjectPreservesThreadIdentity(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) {
|
||||
got := listPinProject(map[string]any{
|
||||
"result": map[string]any{
|
||||
"messages": []any{
|
||||
|
||||
@@ -94,6 +94,13 @@ 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"},
|
||||
@@ -175,6 +182,16 @@ 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 {
|
||||
@@ -203,6 +220,15 @@ 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 {
|
||||
@@ -248,7 +274,7 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
fake := &muteMemberResolutionCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -277,7 +303,7 @@ func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCategoryTitleValidation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationCategoryTitleValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
argv []string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -91,6 +93,62 @@ 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
|
||||
@@ -98,6 +156,12 @@ 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"},
|
||||
@@ -152,6 +216,18 @@ 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) {
|
||||
@@ -177,6 +253,27 @@ 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) {
|
||||
@@ -240,6 +337,45 @@ 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)
|
||||
@@ -388,6 +524,90 @@ 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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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
|
||||
@@ -17,13 +17,22 @@ 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"
|
||||
)
|
||||
|
||||
func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
type chatOutputErrorWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w chatOutputErrorWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
want := []string{
|
||||
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
|
||||
"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",
|
||||
}
|
||||
@@ -54,6 +63,251 @@ func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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...)
|
||||
}
|
||||
@@ -13,44 +13,81 @@ 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 as the current user. It intentionally
|
||||
// does not advertise Lark-only owner, description, initial-bot, or visibility
|
||||
// semantics.
|
||||
// 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.
|
||||
var ChatCreate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-create",
|
||||
Product: "im",
|
||||
Description: "以当前用户身份创建钉钉群聊",
|
||||
Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。",
|
||||
Description: "按成员和可选群主全量预检后创建一个钉钉群聊",
|
||||
Intent: "当你要创建钉钉群聊时使用;成员可传稳定 ID 或 --member-query 姓名,群主默认当前用户,也可用 --owner-open-dingtalk-id 或 --owner-query 明确指定。所有自然身份会在唯一解析并去重后才执行一次创建,任一零命中或多命中都会整体停止。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", 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: "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 "合作群" --users userId1,userId2 --type EXTERNAL`,
|
||||
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
||||
resolvedMembers, err := targetresolver.ResolveUsers(
|
||||
rt,
|
||||
rt.StrSlice("member-query"),
|
||||
targetresolver.IdentityAny,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取当前用户以设置群主失败: %w", err)
|
||||
return err
|
||||
}
|
||||
currentUserID := currentProfileUserID(profile)
|
||||
if currentUserID == "" {
|
||||
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
|
||||
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)
|
||||
}
|
||||
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,
|
||||
@@ -59,6 +96,9 @@ 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)
|
||||
}
|
||||
@@ -114,18 +154,23 @@ func normalizeCreatedConversation(data map[string]any) {
|
||||
var ChatUpdate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update",
|
||||
Aliases: []string{"+chat-rename"},
|
||||
Product: "chat",
|
||||
Description: "更新群名称(仅名称,不支持 description)",
|
||||
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
|
||||
Intent: "当你只需要修改群名称时使用;--group 可传群名或 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": rt.Str("group"),
|
||||
"openconversation_id": resolved.Selected.OpenConversationID,
|
||||
"group_name": rt.Str("name"),
|
||||
})
|
||||
},
|
||||
@@ -138,8 +183,8 @@ var MessagesReply = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-reply",
|
||||
Product: "chat",
|
||||
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
|
||||
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
|
||||
Description: "引用回复一条已有消息,并返回可继续查询或撤回的发送上下文",
|
||||
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -175,10 +220,79 @@ var MessagesReply = shortcut.Shortcut{
|
||||
if value := rt.StrFirst("idempotency-key", "uuid"); value != "" {
|
||||
params["uuid"] = value
|
||||
}
|
||||
return rt.CallMCP("send_personal_message", params)
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -303,8 +417,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"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -330,8 +444,8 @@ var FlagCancel = shortcut.Shortcut{
|
||||
Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -389,8 +503,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: "数字分页游标,首次传 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
|
||||
{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 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
@@ -516,10 +630,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 的别名"},
|
||||
{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 的别名,必须在 1-100 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
@@ -721,7 +835,7 @@ func chatListFilterTypes(chats []map[string]any, types []string) []map[string]an
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ChatCreate,
|
||||
ChatList,
|
||||
ChatUpdate,
|
||||
@@ -730,5 +844,5 @@ func init() {
|
||||
FlagCancel,
|
||||
FlagList,
|
||||
FeedGroupQueryItem,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,14 @@ type larkAlignmentCall struct {
|
||||
}
|
||||
|
||||
type larkAlignmentCaller struct {
|
||||
calls []larkAlignmentCall
|
||||
failTarget string
|
||||
failProductTool string
|
||||
category string
|
||||
responses map[string]string
|
||||
calls []larkAlignmentCall
|
||||
failTarget string
|
||||
failProductTool string
|
||||
failProductToolAt map[string]int
|
||||
callCounts map[string]int
|
||||
category string
|
||||
responses map[string]string
|
||||
sequenceResponses map[string][]string
|
||||
}
|
||||
|
||||
func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -36,9 +39,16 @@ 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":
|
||||
@@ -75,6 +85,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -87,7 +101,67 @@ func (f *larkAlignmentCaller) DryRun() bool { return false }
|
||||
func (f *larkAlignmentCaller) Fields() string { return "" }
|
||||
func (f *larkAlignmentCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
|
||||
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) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -127,7 +201,80 @@ func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -201,7 +348,7 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -222,7 +369,7 @@ func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -232,11 +379,11 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "chat-update-name-only",
|
||||
args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"},
|
||||
args: []string{"chat", "+chat-update", "--group", "cid-fixture-chat-0001", "--name", "新群名", "--yes"},
|
||||
product: "chat",
|
||||
tool: "update_group_name",
|
||||
wantArgs: map[string]any{
|
||||
"openconversation_id": "cid",
|
||||
"openconversation_id": "cid-fixture-chat-0001",
|
||||
"group_name": "新群名",
|
||||
},
|
||||
},
|
||||
@@ -291,10 +438,32 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
func TestCrossPlatformCoverageObservedChatRenameAliasResolvesNameBeforeWrite(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-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",
|
||||
@@ -327,9 +496,46 @@ func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
|
||||
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 TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
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) {
|
||||
fake := &larkAlignmentCaller{failTarget: "m2"}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -341,8 +547,8 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
"--conversation-id", "cid",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("partial batch failure returned success")
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
@@ -359,7 +565,7 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -390,7 +596,7 @@ func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -450,7 +656,7 @@ func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -479,7 +685,7 @@ func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"content": map[string]any{
|
||||
"mentions": []any{
|
||||
@@ -627,7 +833,7 @@ func TestChatListP2POnlyDropsGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
|
||||
conversations := []map[string]any{
|
||||
{"openConversationId": "cid-a", "conversationName": "A"},
|
||||
{"openConversationId": "cid-b", "conversationName": "B"},
|
||||
@@ -645,7 +851,7 @@ func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// 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
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,7 +45,7 @@ var (
|
||||
resourceCopy = io.Copy
|
||||
resourceTempSync = (*os.File).Sync
|
||||
resourceTempClose = (*os.File).Close
|
||||
resourceRename = os.Rename
|
||||
resourceRename = replaceFileAtomically
|
||||
resourceLink = os.Link
|
||||
resourceDownload = downloadResourceAtomically
|
||||
)
|
||||
@@ -58,15 +59,15 @@ var MessagesResourceDownload = shortcut.Shortcut{
|
||||
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
|
||||
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
|
||||
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
|
||||
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
|
||||
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}},
|
||||
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
|
||||
{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: "允许覆盖已存在的目标文件(默认拒绝)"},
|
||||
{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: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{
|
||||
@@ -204,24 +205,35 @@ func validateResourceDownloadOutputFlag(output, flagName string) error {
|
||||
if output == "" {
|
||||
return apperrors.NewValidation(flagName + " 不能为空")
|
||||
}
|
||||
// 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), "/") {
|
||||
if resourcePathIsAbsolute(output) {
|
||||
return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(output))
|
||||
if resourcePathEscapesBase(clean) {
|
||||
if resourcePathEscapesBase(output) {
|
||||
return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 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, "../")
|
||||
}
|
||||
|
||||
func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) {
|
||||
@@ -600,5 +612,5 @@ func downloadResourceAtomically(
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MessagesResourceDownload)
|
||||
shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...)
|
||||
}
|
||||
|
||||
@@ -599,3 +599,221 @@ 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 TestResolveResourceDownloadPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(base, "downloads"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -122,14 +122,30 @@ func TestResolveResourceDownloadPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) {
|
||||
func TestCrossPlatformCoverageValidateResourceDownloadOutputUsesOwningFlagName(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 TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) {
|
||||
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) {
|
||||
base := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(base, "linked")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -18,10 +19,18 @@ 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
|
||||
@@ -30,8 +39,8 @@ var MessagesSend = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-send",
|
||||
Product: "chat",
|
||||
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 等价能力。",
|
||||
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 不支持富媒体。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -45,15 +54,15 @@ var MessagesSend = shortcut.Shortcut{
|
||||
CLIPath: "chat +messages-send",
|
||||
PrimaryCLIPath: "chat +messages-send",
|
||||
},
|
||||
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
|
||||
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||||
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: "统一发送文本、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 等价能力。"},
|
||||
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 不支持富媒体。"},
|
||||
AvoidWhen: []string{"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"},
|
||||
Examples: []string{
|
||||
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
|
||||
@@ -66,7 +75,11 @@ 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);受发送身份能力矩阵约束"},
|
||||
@@ -99,14 +112,14 @@ var MessagesSend = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
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 支持",
|
||||
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 支持",
|
||||
},
|
||||
},
|
||||
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> --users userId1,userId2 --text "请提交周报"`,
|
||||
`dws chat +messages-send --as bot --robot-code <robotCode> --groups <openConversationId1>,<openConversationId2> --text "请提交周报"`,
|
||||
},
|
||||
Validate: validateMessagesSend,
|
||||
Execute: executeMessagesSend,
|
||||
@@ -115,7 +128,13 @@ 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"))
|
||||
@@ -134,29 +153,36 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
switch identity {
|
||||
case "user":
|
||||
targetCount := nonEmptyStringCount(group, userID, openID)
|
||||
targetCount := nonEmptyStringCount(group, chatQuery, userID, userQuery, openID)
|
||||
if targetCount != 1 {
|
||||
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
|
||||
return apperrors.NewValidation("--identity user 时 --group/--chat-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个")
|
||||
}
|
||||
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
|
||||
if len(users) > 0 || len(openIDs) > 0 || len(botGroups) > 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 != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||||
if (userID != "" || userQuery != "" || 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
|
||||
if (group != "") == hasDirect {
|
||||
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
|
||||
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 userID != "" || openID != "" || rt.Str("webhook-token") != "" {
|
||||
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
|
||||
@@ -170,14 +196,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if messagesSendIdempotencyKey(rt) != "" {
|
||||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||||
}
|
||||
if contentType != "text" && contentType != "markdown" {
|
||||
if !messageIdentitySupportsContent(identity, contentType) {
|
||||
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 != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
|
||||
if group != "" || len(botGroups) > 0 || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
|
||||
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
|
||||
}
|
||||
if len(atOpenIDs) > 0 {
|
||||
@@ -186,7 +215,7 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if messagesSendIdempotencyKey(rt) != "" {
|
||||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||||
}
|
||||
if contentType != "text" && contentType != "markdown" {
|
||||
if !messageIdentitySupportsContent(identity, contentType) {
|
||||
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
|
||||
}
|
||||
}
|
||||
@@ -225,33 +254,10 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if contentType == "file" || contentType == "audio" || contentType == "video" {
|
||||
return executeMessagesSendUserFile(rt, group, openID, contentType)
|
||||
}
|
||||
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
|
||||
}
|
||||
params := resolvedUserMarkdownParams(rt, ResolvedUserMessageTarget{
|
||||
GroupID: group,
|
||||
OpenDingTalkID: openID,
|
||||
}, title, body, uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), rt.Bool("at-all"), messagesSendIdempotencyKey(rt))
|
||||
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
|
||||
case "bot":
|
||||
body = helpers.NormalizeMessageMentions(
|
||||
@@ -281,6 +287,31 @@ 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
|
||||
}
|
||||
@@ -393,6 +424,20 @@ 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
|
||||
@@ -400,6 +445,64 @@ 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{
|
||||
@@ -546,6 +649,52 @@ 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 {
|
||||
@@ -556,5 +705,5 @@ func shortcutMessageTitle(text string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MessagesSend)
|
||||
shortcut.Register(withReviewedChatShortcutContracts(MessagesSend)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,98 @@ 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
|
||||
@@ -150,6 +242,88 @@ 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
|
||||
@@ -605,13 +779,35 @@ 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
|
||||
}
|
||||
if value, ok := page["hasMore"]; ok {
|
||||
payload["hasMore"] = value
|
||||
value, hasMoreKnown := page["hasMore"]
|
||||
if !hasMoreKnown {
|
||||
payload["paginationKnown"] = false
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层仅返回 cursor、未返回 hasMore,无法证明结果完整",
|
||||
}}
|
||||
return
|
||||
}
|
||||
payload["paginationKnown"] = true
|
||||
payload["hasMore"] = value
|
||||
if value, ok := page["complete"]; ok {
|
||||
payload["complete"] = value
|
||||
}
|
||||
@@ -621,6 +817,11 @@ 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}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSender(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSender(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,7 +48,37 @@ func TestSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanText(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) {
|
||||
// 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" +
|
||||
@@ -98,7 +128,7 @@ func TestCleanText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEncryptedAndMarker(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIsEncryptedAndMarker(t *testing.T) {
|
||||
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
|
||||
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
|
||||
"||2||1||196"
|
||||
@@ -121,7 +151,7 @@ func TestIsEncryptedAndMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestText(t *testing.T) {
|
||||
func TestCrossPlatformCoverageText(t *testing.T) {
|
||||
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
|
||||
t.Errorf("Text string = %v", got)
|
||||
}
|
||||
@@ -136,7 +166,7 @@ func TestText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTime(t *testing.T) {
|
||||
func TestCrossPlatformCoverageCreateTime(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)
|
||||
}
|
||||
@@ -145,7 +175,7 @@ func TestCreateTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMessageIdentity(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStableMessageIdentity(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -166,7 +196,31 @@ func TestStableMessageIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotedMessageIsBoundedAndSemantic(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) {
|
||||
got := QuotedMessage(map[string]any{
|
||||
"quotedMessage": map[string]any{
|
||||
"openMessageId": "quoted-1",
|
||||
@@ -294,7 +348,7 @@ func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
func TestCrossPlatformCoverageUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
if got := UpdateTime(map[string]any{
|
||||
"createTime": "2026-07-19 13:37:03",
|
||||
"updateTime": "2026-07-19 13:37:03",
|
||||
@@ -309,7 +363,7 @@ func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
got := Reactions(map[string]any{
|
||||
"emotionReplyList": []any{
|
||||
map[string]any{
|
||||
@@ -345,7 +399,7 @@ func TestReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
payload := map[string]any{"count": 98}
|
||||
ApplyPagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
@@ -369,7 +423,7 @@ func TestApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
@@ -389,7 +443,21 @@ func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesBuildsActionableDownloadReferences(t *testing.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) {
|
||||
resources := Resources(map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -416,7 +484,7 @@ func TestResourcesBuildsActionableDownloadReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
|
||||
if len(resources) != 1 {
|
||||
t.Fatalf("resources = %#v", resources)
|
||||
@@ -431,7 +499,7 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
resources := Resources(map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -442,7 +510,7 @@ func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarded(t *testing.T) {
|
||||
func TestCrossPlatformCoverageForwarded(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)}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -982,6 +982,11 @@ 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,
|
||||
@@ -993,6 +998,9 @@ func init() {
|
||||
CommentCreateInline,
|
||||
ExportSubmit,
|
||||
ExportGet,
|
||||
legacyVersionSave,
|
||||
legacyVersionList,
|
||||
legacyVersionRevert,
|
||||
VersionSave,
|
||||
VersionList,
|
||||
VersionRevert,
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// 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,6 +92,7 @@ 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": {},
|
||||
@@ -208,20 +209,48 @@ 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": {},
|
||||
|
||||
@@ -13,6 +13,9 @@ 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"`
|
||||
@@ -30,17 +33,34 @@ type semanticCatalogRecord struct {
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
var reviewedSemanticCatalog = mustLoadSemanticCatalog()
|
||||
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
semanticCatalogJSON,
|
||||
docSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
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(semanticCatalogJSON, &source); err != nil {
|
||||
if err := json.Unmarshal(raw, &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))
|
||||
@@ -76,9 +96,12 @@ func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
|
||||
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
|
||||
command, record.Availability))
|
||||
}
|
||||
out[publicCatalogKey(source.Service, command)] = record
|
||||
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
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
|
||||
|
||||
@@ -137,6 +137,29 @@ 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")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ 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.
|
||||
@@ -86,18 +87,37 @@ var AtMe = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
|
||||
{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: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
|
||||
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()...),
|
||||
Tips: []string{
|
||||
`dws chat +at-me`,
|
||||
`dws chat +at-me --days 3`,
|
||||
`dws chat +at-me --group "项目群"`,
|
||||
},
|
||||
Validate: chatshortcut.ValidateMessageResourceDownload,
|
||||
Validate: validateAtMe,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID := ""
|
||||
directTarget := strings.TrimSpace(rt.Str("group"))
|
||||
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
|
||||
if directTarget != "" || queryTarget != "" {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -111,35 +131,75 @@ 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.
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{
|
||||
params := 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 — project matched messages; fall back to the raw payload when we
|
||||
// cannot locate a recognisable message list.
|
||||
// Step 3 — always publish the stable list envelope, including for an empty
|
||||
// or newly-shaped response. This keeps common .messages[]/.items[] jq
|
||||
// projections deterministic instead of turning absence into null.
|
||||
items := atMeMessageItems(data)
|
||||
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 := map[string]any{"messages": results}
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
payload["items"] = atMeCompatibilityItems(results)
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
|
||||
}
|
||||
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 TestAtMeProject(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAtMeProject(t *testing.T) {
|
||||
// nested sender object + plain text
|
||||
row := atMeProject(map[string]any{
|
||||
"sender": map[string]any{"name": "念晨"},
|
||||
@@ -87,7 +87,7 @@ func TestAtMeProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgProject(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
|
||||
// nested sender + plain text + messageId
|
||||
row := searchMsgProject(map[string]any{
|
||||
"sender": map[string]any{"nick": "千启"},
|
||||
@@ -129,7 +129,7 @@ func TestSearchMsgProject(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 TestSenderHelpers(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSenderHelpers(t *testing.T) {
|
||||
cases := []struct {
|
||||
fn func(map[string]any) any
|
||||
name string
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"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/targetresolver"
|
||||
)
|
||||
|
||||
// Broadcast: send the SAME single-chat message to several people by NAME.
|
||||
@@ -96,14 +97,28 @@ var Broadcast = shortcut.Shortcut{
|
||||
|
||||
// Step 1 — resolve this name to a unique userId. On failure
|
||||
// (unknown / ambiguous) record it and keep going.
|
||||
user, err := resolveOpenDingTalkUser(rt, name)
|
||||
resolved, err := targetresolver.ResolveEnterpriseUser(rt, name, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error()))
|
||||
continue
|
||||
}
|
||||
if user.openDingTalkID == "" {
|
||||
failed = append(failed, fmt.Sprintf("%s(通讯录结果缺少 openDingTalkId)", name))
|
||||
continue
|
||||
user := resolved.Selected
|
||||
targetArgs := map[string]any{}
|
||||
if user.OpenDingTalkID != "" {
|
||||
targetArgs["receiverOpenDingTalkId"] = user.OpenDingTalkID
|
||||
} else {
|
||||
targetArgs["receiverUserId"] = user.UserID
|
||||
}
|
||||
recipient := user.Name
|
||||
if recipient == "" {
|
||||
recipient = name
|
||||
}
|
||||
messageArgs := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})
|
||||
for key, value := range targetArgs {
|
||||
messageArgs[key] = value
|
||||
}
|
||||
|
||||
// Step 2 — send the single-chat message to this recipient. Under
|
||||
@@ -111,26 +126,18 @@ var Broadcast = shortcut.Shortcut{
|
||||
// resolved recipient as "would send" and move on.
|
||||
if rt.DryRun() {
|
||||
plans = append(plans, map[string]any{
|
||||
"recipient": user.name,
|
||||
"recipient": recipient,
|
||||
"tool": "send_personal_message",
|
||||
"arguments": rt.AddAIMessageTag(map[string]any{
|
||||
"receiverOpenDingTalkId": user.openDingTalkID,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
}),
|
||||
"arguments": messageArgs,
|
||||
})
|
||||
sent = append(sent, user.name)
|
||||
sent = append(sent, recipient)
|
||||
continue
|
||||
}
|
||||
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", rt.AddAIMessageTag(map[string]any{
|
||||
"receiverOpenDingTalkId": user.openDingTalkID,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})); err != nil {
|
||||
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", messageArgs); err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s(发送失败:%s)", name, err.Error()))
|
||||
continue
|
||||
}
|
||||
sent = append(sent, user.name)
|
||||
sent = append(sent, recipient)
|
||||
}
|
||||
|
||||
// Summarize via rt.Output (structured, honours --format/--jq/--fields)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user