Compare commits

...
Author SHA1 Message Date
chichuan 9dbbd64f3c Merge branch 'main' into codex/im-shortcut-gap-fill 2026-07-30 11:31:19 +08:00
github-actions[bot] 7ba12a8e4c chore: update formula for v1.0.55 [skip ci] 2026-07-30 03:11:41 +00:00
chichuan 82d02096f7 Merge pull request #833 from DingTalk-Real-AI/codex/changelog-v1.0.55-promote-beta.8
docs(release): promote v1.0.55-beta.8 baseline
2026-07-30 11:00:51 +08:00
Dennis 41372b0597 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:32:58 +08:00
chichuan ad08b6b499 docs(release): promote v1.0.55-beta.8 2026-07-30 10:32:20 +08:00
Dennis cffc48406c fix(im): resolve direct recipients via contact search 2026-07-30 10:29:39 +08:00
github-actions[bot] 250aab3ef1 chore: update beta formula for v1.0.55-beta.8 [skip ci] 2026-07-30 02:28:33 +00:00
chichuan e36b6dc049 Merge pull request #832 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.8
docs(release): add v1.0.55-beta.8 notes
2026-07-30 10:19:15 +08:00
Dennis f9e3476d42 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:02:34 +08:00
chichuan d9d62fb2f7 docs(release): add v1.0.55-beta.8 notes 2026-07-30 09:55:16 +08:00
github-actions[bot] f9f61a4cc6 Merge pull request #825 from DingTalk-Real-AI/codex/changelog-v1.0.55
docs(release): add v1.0.55 stable notes
2026-07-30 09:39:02 +08:00
Dennis 2b48f27a4b fix(im): harden shortcut review follow-ups 2026-07-29 23:35:52 +08:00
Dennis bf79a67efe fix(im): close shortcut review gaps 2026-07-29 21:25:24 +08:00
chichuan 8d22cd553a docs(release): add v1.0.55 stable notes 2026-07-29 20:42:19 +08:00
Dennis 95d262bbb2 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 19:32:18 +08:00
Dennis d5c260c7c0 fix(im): address shortcut review regressions 2026-07-29 19:31:48 +08:00
github-actions[bot] 4f31863aae chore: update beta formula for v1.0.55-beta.7 [skip ci] 2026-07-29 10:19:40 +00:00
chichuan 6de2bf1518 docs(release): 合入 beta.7 发布说明(风险等级:低)
发布模块:CHANGELOG。补充 v1.0.55-beta.7 的完整变更说明,并保留失败 beta.6 的审计记录。风险等级:低。
2026-07-29 18:09:24 +08:00
Dennis 9c297d0520 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 18:02:49 +08:00
chichuan 78b724480e docs(release): 补充 beta.7 完整发布说明(风险等级:低) 2026-07-29 18:02:40 +08:00
Dennis 37230d2d4d chore(schema): refresh shortcut skill source hashes 2026-07-29 18:01:54 +08:00
github-actions[bot] 4724c30f4b Merge pull request #821 from typefield/agent/restore-shared-account-rule
fix(skills): restore multi-account safety rule in dws-shared SKILL.md
2026-07-29 17:56:16 +08:00
Dennis fde6b59074 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
2026-07-29 17:51:27 +08:00
玉澜 76b9f1536a test(app): pin multi-account safety rule in embedded dws-shared skill
Replace the CI classifier change with a real PR-level regression
contract: materialize the embedded multi skill source and assert
dws-shared/SKILL.md keeps the 禁止选择第一项、最近登录或最近使用账号 rule
that the MultiSkill e2e release gate requires. The new test file also
makes the revision full-suite so all quality gates run on this PR.
2026-07-29 17:42:04 +08:00
玉澜 809b9b3570 ci: classify skills/ changes as docs-only for fast path
Skill markdown files are agent documentation embedded at build time;
they carry no Go code changes. Without this classification a one-line
SKILL.md edit triggers the full -race test suite on internal/app and
reverse dependencies, which exceeds the 8m job timeout and fails CI
deterministically.
2026-07-29 17:36:56 +08:00
玉澜 0be5b73518 fix(skills): restore multi-account safety rule in dws-shared SKILL.md
Commit dc20ddec dropped the 禁止选择第一项、最近登录或最近使用账号 rule
from dws-shared/SKILL.md during the multi-skill refactor while the
MultiSkill e2e contract still asserts it there, blocking the
v1.0.55-beta.6 release run. Restore the rule as a mandatory-contract
bullet pointing at dingtalk-profile/SKILL.md for the full selection and
cross-org rules.
2026-07-29 17:19:31 +08:00
chichuan a637a44b7a docs(release): 恢复 beta.6 main admission(风险等级:低)
明确 beta.6 五个 PR 审计范围,并由真实用户合入以触发 main CHANGELOG fast-path CI。
2026-07-29 16:52:33 +08:00
github-actions[bot] 579eed81d9 Merge pull request #818 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.6
docs(release): prepare v1.0.55-beta.6 changelog
2026-07-29 16:38:54 +08:00
chichuan c68d9facb2 docs(release): 补充 CHANGELOG beta.6 五项合入说明(风险等级:低) 2026-07-29 16:33:48 +08:00
github-actions[bot] 1f9138e99a Merge pull request #621 from typefield/agent/sync-wukong-multi-skill
feat(skills): add  multi-skill framework to DWS
2026-07-29 16:24:53 +08:00
玉澜 c3fd814630 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
2026-07-29 16:08:16 +08:00
github-actions[bot] 5922a0717a Merge pull request #816 from wxianfeng/feature/aone82250541-agent-product
feat: support configurable Agent Product identity
2026-07-29 16:04:24 +08:00
玉澜 c5bc1fdad4 Merge remote-tracking branch 'typefield/agent/sync-wukong-multi-skill' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_parameter_bindings.json
2026-07-29 15:51:04 +08:00
玉澜 9567cfd3d8 fix(review): align wukong port with upstream behavior and PR #621 review findings
Must-fix: drive permission apply now gates on confirmDangerousAction and
declares confirmation=user_required, matching its help-text promise.

Wukong parity restored: formula-verify --exit-on-error (payload-parsing
exit path) and --targets conflict error, sheet info --include, chat
location/profile message types, search-advanced wukong flag aliases,
and a dedicated drive download-version leaf replacing the removed
polymorphic download --version.

Consistency fixes: transfer-owner --node/--workspace XOR and JSON-aware
dry-run after --yes validation; drive list --versions rejects
--depth/--pattern instead of misleading depth errors; depth BFS resumes
rate-limited folders from the failed page cursor to avoid duplicates;
doc style cover upload honors cmd.Context() and a 20 MiB size cap; chat
user-settings set validates per-item openConversationId and is
risk=medium.

Hardened the skill static audit to scan fenced code blocks and reject
unknown subcommands on group commands, fixing the stale aitable/drive
doc examples it exposed. Added CHANGELOG entry and coverage tests for
all changed statements plus previously untested ported commands.
2026-07-29 15:34:59 +08:00
wxianfeng 4567dd1cd6 fix(im): gate optional resource downloads at runtime 2026-07-29 15:33:01 +08:00
chichuan 1180510f40 merge(agent-product): 同步 main 并解决 CHANGELOG 冲突(风险等级:高)
保留 #816 的 Agent Product 身份说明与 main 中已合入的 Shortcut 修复条目,并完成全仓测试、构建及 Schema 生成漂移校验。
2026-07-29 15:19:28 +08:00
Dennis 75bb01bb64 docs(skill): align IM shortcut routing 2026-07-29 14:45:28 +08:00
chichuan 2456660780 test(chat): 补齐文字表情跨平台覆盖(风险:低)
让 update-text-emotion 映射与缺参测试进入 Darwin/Windows coverage 矩阵,并移除已由 Cobra 必填门禁覆盖的不可达重复校验。
2026-07-29 14:36:41 +08:00
Dennis 11a7ab8b7c fix(im): harden shortcut downloads and message context 2026-07-29 14:20:39 +08:00
chichuan c3dbe866c4 feat(chat): 补齐文字表情原地更新契约(风险:低)
基于 PR #621 现有 update-text-emotion 实现,补齐七参数 RPC 映射、Cobra/Schema 必填约束、mono Skill、CHANGELOG 与别名/缺参回归测试。
2026-07-29 14:17:03 +08:00
wxianfeng 81f130c483 fix: address agent product review feedback 2026-07-29 13:56:25 +08:00
玉澜 6b99685594 fix(schema): bump runtime-surface completeness source_tools to 839
The 26 newly registered commands raised the registry count to 839, but
runtime-surface-completeness.json still declared source_tools=813, so
check-schema-catalog.sh failed the Policy job ("runtime-surface
completeness source must remain unreviewed and interface-free"). The 26
tools are all reviewed in metadata/selection sources, so the unreviewed
71-tool list is unchanged; regenerate dependent schema artifacts.
2026-07-29 13:51:25 +08:00
Dennis 41e0fb381a feat(im): close shortcut capability gaps 2026-07-29 13:29:53 +08:00
玉澜 9d59550890 test(helpers): cover new drive/doc-style/sheet/chat commands to 100% changed-code coverage
The CI platform coverage gate enforces 100% coverage of changed
statements via tests named TestCrossPlatformCoverage*/TestAllShortcuts.
Add unit tests for drive list --depth BFS (pagination, rate-limit retry,
dedup, truncation, SIGINT, anomalies), drive list --versions/transfer-
owner/cover/revert paths, doc style cover upload flow, sheet
formula-verify target parsing, and chat group user-settings validation.
Also drop an unreachable resourceID guard in uploadDocStyleImage.
2026-07-29 13:29:22 +08:00
玉澜 83f13d8e11 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-29 12:25:45 +08:00
wxianfeng 86bce64cc8 test: cover agent product header branches 2026-07-29 12:06:43 +08:00
玉澜 68e1a78810 feat(cli): port drive list --depth and doc style, register all new commands in Schema
- Port drive list --depth N BFS recursive listing (pan + workspace routes,
  rate-limit requeue, SIGINT partial emit, --pattern/--quiet)
- Port doc style cover set/clear, background set/clear, get with local
  image validation and attachment-upload subflow
- Register all 26 newly ported commands in schema_command_registry with
  reviewed metadata/selection hints instead of exclusions (813->839 tools)
- Review fixes: drive list --node usage text no longer implies required
  in agent schema; remove broken formula-verify --exit-on-error; error on
  --range without --sheet-id; portable stdin read; drop local --yes
  shadowing root -y on drive revert/transfer-owner; use
  confirmDangerousAction for non-delete confirms; explicit
  recursiveChange=false now transmitted; sheet version revert and
  comment delete moved into sheet confirmationGuards registry
2026-07-29 11:57:57 +08:00
玉澜 e63a4bdf47 feat(cli): add chat group get-mute-config command from wukong develop 2026-07-29 11:18:49 +08:00
github-actions[bot] 6ab01a365e Merge pull request #815 from DingTalk-Real-AI/codex/im-shortcut-optimization
feat(chat): harden and publish IM shortcuts
2026-07-29 11:05:56 +08:00
玉澜 d855edaad2 feat(cli): add chat message update-text-emotion command 2026-07-29 11:03:04 +08:00
玉澜 75fce5c4ff Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 10:53:12 +08:00
玉澜 d28a50c0f4 fix(cli): resolve schema parameter mapping for drive download
Remove --version flag from drive download (polymorphic tool dispatch
incompatible with schema validation). Regenerate schema catalog and
add new commands to schema_command_exclusions.json.
2026-07-29 10:12:15 +08:00
玉澜 bbecd2f3a6 style: gofmt chat.go 2026-07-29 09:23:27 +08:00
玉澜 a705c9de0b feat(cli): implement wukong-internal commands in open-source CLI
Port 19 command leaves from wukong internal CLI:
- drive star add/remove/list (文档收藏)
- drive cover (节点封面)
- drive revert (文件版本回滚)
- drive list --versions / download --version (文件历史版本)
- drive permission transfer-owner/apply-info/apply
- sheet version save/list/revert
- sheet formula-verify
- sheet comment list/create/reply/update/delete
- chat group user-settings query/set

Restore corresponding skill docs and register commands in schema
exclusions pending Schema review.
2026-07-29 01:06:36 +08:00
玉澜 f5d57c2e07 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync 2026-07-29 00:32:22 +08:00
玉澜 967cf26d44 fix(skills): remove commands absent from open-source CLI
Remove references to wukong-internal-only commands that fail CI
Interface Integrity: drive permission transfer-owner/apply/apply-info,
drive star/cover/revert/list --versions, sheet comment/formula-verify/
version, chat group user-settings. Delete sheet-comment.md and
sheet-version.md entirely.
2026-07-28 22:52:46 +08:00
玉澜 d31cae2b0c Revert "docs(skills): add create→transfer-owner bridge for group owner scenario"
This reverts commit 4e71f56f97.
2026-07-28 22:04:21 +08:00
wxianfeng e998e2609d feat: support agent product identity to #82250541 2026-07-28 21:46:20 +08:00
玉澜 4e71f56f97 docs(skills): add create→transfer-owner bridge for group owner scenario
group create does not support --owner; agents need an explicit pointer
to transfer-owner when users ask to specify a group owner at creation.
2026-07-28 21:44:59 +08:00
玉澜 69b8df3e40 fix(skills): reconcile wukong sync with latest main CLI surface
Restore capabilities now supported on main (doc read --scope/--tags,
drive upload --node overwrite, chat category, dingtalk-markdown routing),
remove commands still absent from the open-source CLI (calendar event
instances, sheet info --include, chat group create --owner), remap
folded services (attendance/ding/oa/report/sheet) to dingtalk-misc in
the shortcut generator, and regenerate shortcut sections and schema
metadata.
2026-07-28 20:56:37 +08:00
玉澜 8d988bc350 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	skills/multi/dingtalk-aitable/SKILL.md
#	skills/multi/dingtalk-attendance/SKILL.md
#	skills/multi/dingtalk-calendar/SKILL.md
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
#	skills/multi/dingtalk-contact/SKILL.md
#	skills/multi/dingtalk-contact/references/contact.md
#	skills/multi/dingtalk-ding/SKILL.md
#	skills/multi/dingtalk-doc/SKILL.md
#	skills/multi/dingtalk-doc/references/doc.md
#	skills/multi/dingtalk-doc/references/doc/doc-comment.md
#	skills/multi/dingtalk-doc/references/doc/doc-read.md
#	skills/multi/dingtalk-drive/SKILL.md
#	skills/multi/dingtalk-drive/references/drive.md
#	skills/multi/dingtalk-mail/SKILL.md
#	skills/multi/dingtalk-minutes/SKILL.md
#	skills/multi/dingtalk-oa/SKILL.md
#	skills/multi/dingtalk-report/SKILL.md
#	skills/multi/dingtalk-sheet/SKILL.md
#	skills/multi/dingtalk-todo/SKILL.md
#	skills/multi/dingtalk-todo/references/todo.md
#	skills/multi/dingtalk-wiki/SKILL.md
#	skills/multi/dws-shared/SKILL.md
2026-07-28 20:25:16 +08:00
玉澜 dc20ddecf6 feat(skills): sync wukong 13-sub-skill multi layout with open-source cleanup
Replace skills/multi with wukong's consolidated structure (long-tail
products folded into dingtalk-misc), keeping GitHub-only skills
(dingtalk-dev/event/pat/profile/skill). Prune MCP-only product refs and
align all documented commands/flags with the open-source Cobra tree:
remove markdown/*, drive task get, drive version flags, doc read
--scope, --async modes, retired conference/chat-file-upload mentions.
2026-07-28 20:20:12 +08:00
玉澜 a09790fc3f Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	test/skill_static/skill_static_test.go
2026-07-28 18:10:50 +08:00
玉澜 52045fb290 Merge upstream/main into agent/sync-wukong-multi-skill 2026-07-16 18:17:17 +08:00
玉澜 275c3430b8 fix(skills): reconcile multi-skill runtime contracts 2026-07-15 10:26:51 +08:00
玉澜 56116bf99e feat(skills): align Wukong multi-skill docs 2026-07-15 01:17:45 +08:00
329 changed files with 69197 additions and 11030 deletions
+83 -2
View File
@@ -6,10 +6,91 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.55-beta.8] - 2026-07-30
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
guarded release delivery. It carries no new product-facing command behavior;
the new version is required because the published beta.7 artifacts succeeded
on GitHub, npm, and Homebrew, but its enabled optional Gitee mirror failed and
left that Release run ineligible for stable promotion.
### Changed
- **Complete promotion evidence** — republishes the validated v1.0.55 command, Runtime Schema, Skill, authentication, and projection changes with the optional Gitee upload fallback disabled, so the release can produce one successful auditable delivery proof before stable promotion.
## [1.0.55] - 2026-07-30
This release promotes the validated `v1.0.55-beta.8` baseline to stable. It
expands the public Workspace command surface and personal event consumption,
makes the full built-in shortcut catalog available to Agents, and hardens
multi-account routing, authentication compatibility, command safety, and
response projection across the CLI.
### Added
- **Broader Workspace command surface** (#621, #676) — adds roughly 30 reviewed Drive, Doc, Sheet, and Chat leaf commands synchronized from Wukong, including Drive version and permission operations, document styling, Sheet comment/version/formula verification, and in-place text-emotion updates. A reusable declarative `LeafSpec` framework now delivers command identity, safety, selection, and guarded Help metadata consistently.
- **Complete Agent-visible shortcut delivery** (#802, #815) — publishes all 210 built-in shortcuts as reviewed Runtime Schema leaves across 16 products, including 88 validated Chat shortcuts, with executable paths, parameters, constraints, selection guidance, dry-run capabilities, and runtime-aligned confirmation semantics.
- **Expanded enterprise and event capabilities** (#790) — adds the HR Brain talent-pool, employee-profile, and structured-search command families; `dws mcp url get` resolves MCP Market endpoints; personal event consumption supports eight additional IM event keys, multi-key consumers, and targeted shutdown.
- **Agent integration identity** (#804, #816) — adds validated `DWS_AGENT_HOST` and `DWS_AGENT_PRODUCT` labels for observability and product attribution while keeping them separate from authentication and authorization.
### Changed
- **Progressive multi-Skill guidance and account safety** (#621, #821) — reorganizes bundled product guidance for progressive discovery and restores the mandatory rule that Agents must not guess an account when a multi-account organization has no unique current default.
- **Supported Chat file delivery** — retires the legacy AppKey/AppSecret-backed `chat media upload` command from discovery and routes local files through `chat message send --msg-type file --file-path`, while callers with an existing media ID can continue sending images directly.
- **Guarded release delivery** (#791) — strengthens immutable GitHub, npm, Homebrew, optional mirror, recovery, and version-allocation checks while keeping beta and stable publication role-gated and auditable.
### Fixed
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
- **Shortcut and message projection correctness** (#706, #783, #795) — prevents successful read shortcuts from silently projecting non-empty backend responses to empty results, renders rich, forwarded, and encrypted message forms safely, and fixes group-bot, bot-search, mail-thread, media-ID alias, and Todo paging response handling.
- **Command contract edge cases** (#803) — makes approval revocation and document rollback honor dry-run before confirmation or preflight, fixes Drive and Doc rename semantics, restores Drive-specific metadata, and validates Todo reminder rules.
- **Authentication and external-contact compatibility** (#756, #757) — migrates legacy global and organization-scoped credentials without cross-account token borrowing, preserves contacts that expose only `openDingTalkId`, and aligns message-resource flags with message-list output fields.
## [1.0.55-beta.7] - 2026-07-29
This beta supersedes the unpublished `v1.0.55-beta.6` candidate and packages
PRs #621, #676, #757, #815, #816, and #821. It restores the mandatory
multi-account safety rule caught by the sealed-release E2E gate while retaining
the reviewed Wukong capability and multi-Skill synchronization, declarative
command and Schema delivery, hardened Chat shortcuts, external contact
resolution, and Agent product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
- **Multi-account Skill safety contract** (#821) — restores the mandatory rule that an Agent must never choose the first, most recently logged-in, or most recently used account when an organization has multiple accounts without one unique `isOrgCurrent=true` default. A PR-level embedded-Skill regression test now catches removal before the full sealed-release E2E gate.
## [1.0.55-beta.6] - 2026-07-29
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
capability and multi-Skill synchronization, declarative command and Schema
delivery, hardened Chat shortcuts, external contact resolution, and Agent
product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
## [1.0.55-beta.5] - 2026-07-28
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55-beta.5"
version "1.0.55-beta.8"
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.55-beta.5/dws-darwin-arm64.tar.gz"
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-arm64.tar.gz"
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.54"
version "1.0.55"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
end
def install
+38 -1
View File
@@ -5,7 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -13,6 +14,42 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
For QwenWork, report the dimensions separately:
```bash
DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
+12 -7
View File
@@ -24,6 +24,7 @@ import (
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
@@ -32,23 +33,27 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
Example: "qwenwork_cloud",
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-provided observation
// label. CR/LF is rejected before trimming so it can never be hidden at the
// edge of a value. An unset or whitespace-only value means "do not emit".
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.TrimSpace(raw)
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
@@ -59,7 +64,7 @@ func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+14 -4
View File
@@ -21,6 +21,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -32,12 +33,14 @@ func TestParseAgentHost(t *testing.T) {
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
@@ -64,6 +67,12 @@ func TestParseAgentHost(t *testing.T) {
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
@@ -91,6 +100,7 @@ func TestParseAgentHost(t *testing.T) {
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
+74
View File
@@ -0,0 +1,74 @@
// 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 (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Example: "qwenwork",
})
}
// parseAgentProduct converts the reusable package error into the CLI's stable
// structured validation error without exposing the untrusted raw value.
func parseAgentProduct(raw string) (string, error) {
value, err := agentproduct.Parse(raw)
if err != nil {
return "", invalidAgentProductError()
}
return value, nil
}
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
if headers == nil {
headers = make(map[string]string)
}
headers[agentproduct.HeaderName] = value
return headers
}
+267
View File
@@ -0,0 +1,267 @@
// 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 (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
}
}
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
const invalidValue = "DO_NOT ECHO"
got, err := parseAgentProduct(invalidValue)
if got != "" {
t.Fatalf("parseAgentProduct() = %q, want empty", got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_product" {
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
if got := headers["x-enterprise-header"]; got != "preserved" {
t.Fatalf("enterprise header = %q, want preserved", got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
}
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "invalid product")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
}
}
+2
View File
@@ -17,6 +17,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -346,6 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
t.Setenv(agentproduct.EnvName, "")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
@@ -0,0 +1,30 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
t.Fatalf("materialize embedded multi skill source: %v", err)
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+2 -1
View File
@@ -570,7 +570,8 @@ func handlePatAuthCheck(
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// edition.MergeHeaders and surfaced in hostControl for traceability.
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+5 -3
View File
@@ -30,6 +30,7 @@ import (
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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -1006,10 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType — the open-source build pins that to the
// literal edition.DefaultOSSClawType value ("openClaw").
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
@@ -1053,7 +1055,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
t.Fatalf("hostControl.clawType = %q, want openClaw (open-source edition default)", got)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
+14 -13
View File
@@ -27,11 +27,11 @@ import (
//
// Decision rule:
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST
// be the exact value the CLI actually injects on the wire into the
// `claw-type` HTTP header. The open-source build pins that to
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
// is no per-spawn env override.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -48,15 +48,16 @@ func hostControlProviderFromEnv() string {
return effectiveClawType()
}
// effectiveClawType returns the literal value that MergeHeaders will
// inject into outbound `claw-type` headers. Going through the edition
// hook (instead of a hard-coded constant) keeps this site correct for
// downstream editions that override MergeHeaders.
// effectiveClawType resolves the literal value injected into outbound
// `claw-type` headers without invoking credential hooks from PAT error
// serialization. MergeHeaders implementations that set claw-type must satisfy
// the edition contract that this value is independent of the base map.
func effectiveClawType() string {
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
return v
headers := make(map[string]string)
if h := edition.Get(); h != nil {
if h.MergeHeaders != nil {
headers = h.MergeHeaders(headers)
}
}
return edition.DefaultOSSClawType
return resolveEffectiveAgentProduct(headers)
}
+5 -1
View File
@@ -41,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -353,12 +354,15 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Validate the optional observation label before any edition hook
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
+19 -5
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -1004,10 +1005,10 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which the
// open-source edition pins to edition.DefaultOSSClawType via the
// MergeHeaders hook below) and it does NOT influence the host-owned
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
sessionID = os.Getenv(envDWSSessionID)
@@ -1056,7 +1057,7 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
@@ -1067,9 +1068,22 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
return headers
}
@@ -17,7 +17,7 @@ import (
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 210
schemaPublishedShortcutCount = 215
)
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 120; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -116,8 +116,8 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
"绝对路径",
"..",
"最多 15 个字符",
"能力矩阵",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 813,
"tools_with_metadata": 813,
"tools_with_agent_summary": 813,
"tools_with_use_when": 813,
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"surface_tools": 845,
"tools_with_metadata": 845,
"tools_with_agent_summary": 845,
"tools_with_use_when": 845,
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -168,7 +168,6 @@
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
@@ -205,15 +204,11 @@
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
"chat +messages-unset-top"
]
}
]
@@ -1,6 +1,174 @@
{
"id": "chat",
"tools": [
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
},
{
"canonical_path": "chat.shortcut_bot_find",
"cli_path": "chat +bot-find"
},
{
"canonical_path": "chat.shortcut_bot_search",
"cli_path": "chat +bot-search"
},
{
"canonical_path": "chat.shortcut_broadcast",
"cli_path": "chat +broadcast"
},
{
"canonical_path": "chat.shortcut_category_create",
"cli_path": "chat +category-create"
},
{
"canonical_path": "chat.shortcut_category_delete",
"cli_path": "chat +category-delete"
},
{
"canonical_path": "chat.shortcut_category_list",
"cli_path": "chat +category-list"
},
{
"canonical_path": "chat.shortcut_category_rename",
"cli_path": "chat +category-rename"
},
{
"canonical_path": "chat.shortcut_chat_bots",
"cli_path": "chat +chat-bots"
},
{
"canonical_path": "chat.shortcut_chat_dismiss",
"cli_path": "chat +chat-dismiss"
},
{
"canonical_path": "chat.shortcut_chat_invite_url",
"cli_path": "chat +chat-invite-url"
},
{
"canonical_path": "chat.shortcut_chat_list_all",
"cli_path": "chat +chat-list-all"
},
{
"canonical_path": "chat.shortcut_chat_list_join_requests",
"cli_path": "chat +chat-list-join-requests"
},
{
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
},
{
"canonical_path": "chat.shortcut_chat_role_add",
"cli_path": "chat +chat-role-add"
},
{
"canonical_path": "chat.shortcut_chat_role_list",
"cli_path": "chat +chat-role-list"
},
{
"canonical_path": "chat.shortcut_chat_role_query_user",
"cli_path": "chat +chat-role-query-user"
},
{
"canonical_path": "chat.shortcut_chat_role_set_user",
"cli_path": "chat +chat-role-set-user"
},
{
"canonical_path": "chat.shortcut_chat_role_update",
"cli_path": "chat +chat-role-update"
},
{
"canonical_path": "chat.shortcut_chat_search",
"cli_path": "chat +chat-search"
},
{
"canonical_path": "chat.shortcut_chat_set_admin",
"cli_path": "chat +chat-set-admin"
},
{
"canonical_path": "chat.shortcut_chat_set_history",
"cli_path": "chat +chat-set-history"
},
{
"canonical_path": "chat.shortcut_chat_update_alias",
"cli_path": "chat +chat-update-alias"
},
{
"canonical_path": "chat.shortcut_chat_update_nick",
"cli_path": "chat +chat-update-nick"
},
{
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
"cli_path": "chat +conversation-clear-all-red-point"
},
{
"canonical_path": "chat.shortcut_conversation_info",
"cli_path": "chat +conversation-info"
},
{
"canonical_path": "chat.shortcut_conversation_list",
"cli_path": "chat +conversation-list"
},
{
"canonical_path": "chat.shortcut_conversation_list_top",
"cli_path": "chat +conversation-list-top"
},
{
"canonical_path": "chat.shortcut_dm",
"cli_path": "chat +dm"
},
{
"canonical_path": "chat.shortcut_group_members",
"cli_path": "chat +group-members"
},
{
"canonical_path": "chat.shortcut_messages_list_direct",
"cli_path": "chat +messages-list-direct"
},
{
"canonical_path": "chat.shortcut_messages_list_pin",
"cli_path": "chat +messages-list-pin"
},
{
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
"cli_path": "chat +messages-list-unread-conversations"
},
{
"canonical_path": "chat.shortcut_messages_mget",
"cli_path": "chat +messages-mget"
},
{
"canonical_path": "chat.shortcut_messages_query_send_status",
"cli_path": "chat +messages-query-send-status"
},
{
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
},
{
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
@@ -9,6 +177,10 @@
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
@@ -17,6 +189,10 @@
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
@@ -41,6 +217,10 @@
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
@@ -89,10 +269,26 @@
"canonical_path": "chat.update_group_icon",
"cli_path": "chat group update-icon"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
},
{
"canonical_path": "chat.update_group_settings",
"cli_path": "chat group update-settings"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
@@ -157,6 +353,10 @@
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
@@ -277,6 +477,10 @@
"canonical_path": "chat.update_streaming_card",
"cli_path": "chat message update-card"
},
{
"canonical_path": "chat.update_text_emotion",
"cli_path": "chat message update-text-emotion"
},
{
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
@@ -294,192 +498,24 @@
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.shortcut_bot_search",
"cli_path": "chat +bot-search"
},
{
"canonical_path": "chat.shortcut_bot_find",
"cli_path": "chat +bot-find"
},
{
"canonical_path": "chat.shortcut_conversation_info",
"cli_path": "chat +conversation-info"
},
{
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
"cli_path": "chat +conversation-clear-all-red-point"
},
{
"canonical_path": "chat.shortcut_conversation_list",
"cli_path": "chat +conversation-list"
},
{
"canonical_path": "chat.shortcut_conversation_list_top",
"cli_path": "chat +conversation-list-top"
},
{
"canonical_path": "chat.shortcut_category_list",
"cli_path": "chat +category-list"
},
{
"canonical_path": "chat.shortcut_category_create",
"cli_path": "chat +category-create"
},
{
"canonical_path": "chat.shortcut_category_delete",
"cli_path": "chat +category-delete"
},
{
"canonical_path": "chat.shortcut_category_rename",
"cli_path": "chat +category-rename"
},
{
"canonical_path": "chat.shortcut_chat_search",
"cli_path": "chat +chat-search"
},
{
"canonical_path": "chat.shortcut_chat_invite_url",
"cli_path": "chat +chat-invite-url"
},
{
"canonical_path": "chat.shortcut_chat_dismiss",
"cli_path": "chat +chat-dismiss"
},
{
"canonical_path": "chat.shortcut_chat_set_history",
"cli_path": "chat +chat-set-history"
},
{
"canonical_path": "chat.shortcut_chat_update_nick",
"cli_path": "chat +chat-update-nick"
},
{
"canonical_path": "chat.shortcut_chat_update_alias",
"cli_path": "chat +chat-update-alias"
},
{
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_list_all",
"cli_path": "chat +chat-list-all"
},
{
"canonical_path": "chat.shortcut_chat_list_join_requests",
"cli_path": "chat +chat-list-join-requests"
},
{
"canonical_path": "chat.shortcut_chat_bots",
"cli_path": "chat +chat-bots"
},
{
"canonical_path": "chat.shortcut_chat_set_admin",
"cli_path": "chat +chat-set-admin"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
},
{
"canonical_path": "chat.shortcut_chat_role_list",
"cli_path": "chat +chat-role-list"
},
{
"canonical_path": "chat.shortcut_chat_role_add",
"cli_path": "chat +chat-role-add"
},
{
"canonical_path": "chat.shortcut_chat_role_update",
"cli_path": "chat +chat-role-update"
},
{
"canonical_path": "chat.shortcut_chat_role_set_user",
"cli_path": "chat +chat-role-set-user"
},
{
"canonical_path": "chat.shortcut_chat_role_query_user",
"cli_path": "chat +chat-role-query-user"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_list_direct",
"cli_path": "chat +messages-list-direct"
},
{
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
"cli_path": "chat +messages-list-unread-conversations"
},
{
"canonical_path": "chat.shortcut_messages_mget",
"cli_path": "chat +messages-mget"
},
{
"canonical_path": "chat.shortcut_messages_query_send_status",
"cli_path": "chat +messages-query-send-status"
},
{
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
},
{
"canonical_path": "chat.shortcut_messages_list_pin",
"cli_path": "chat +messages-list-pin"
},
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
},
{
"canonical_path": "chat.shortcut_broadcast",
"cli_path": "chat +broadcast"
},
{
"canonical_path": "chat.shortcut_dm",
"cli_path": "chat +dm"
},
{
"canonical_path": "chat.shortcut_group_members",
"cli_path": "chat +group-members"
},
{
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
@@ -1,6 +1,74 @@
{
"id": "doc",
"tools": [
{
"canonical_path": "doc.shortcut_comment_create",
"cli_path": "doc +comment-create"
},
{
"canonical_path": "doc.shortcut_comment_list",
"cli_path": "doc +comment-list"
},
{
"canonical_path": "doc.shortcut_comment_reply",
"cli_path": "doc +comment-reply"
},
{
"canonical_path": "doc.shortcut_copy",
"cli_path": "doc +copy"
},
{
"canonical_path": "doc.shortcut_doc_append",
"cli_path": "doc +doc-append"
},
{
"canonical_path": "doc.shortcut_export_get",
"cli_path": "doc +export-get"
},
{
"canonical_path": "doc.shortcut_export_submit",
"cli_path": "doc +export-submit"
},
{
"canonical_path": "doc.shortcut_find_doc",
"cli_path": "doc +find-doc"
},
{
"canonical_path": "doc.shortcut_list",
"cli_path": "doc +list"
},
{
"canonical_path": "doc.shortcut_move",
"cli_path": "doc +move"
},
{
"canonical_path": "doc.shortcut_search",
"cli_path": "doc +search"
},
{
"canonical_path": "doc.shortcut_share_doc",
"cli_path": "doc +share-doc"
},
{
"canonical_path": "doc.shortcut_template_list",
"cli_path": "doc +template-list"
},
{
"canonical_path": "doc.shortcut_template_search",
"cli_path": "doc +template-search"
},
{
"canonical_path": "doc.shortcut_version_list",
"cli_path": "doc +version-list"
},
{
"canonical_path": "doc.shortcut_version_revert",
"cli_path": "doc +version-revert"
},
{
"canonical_path": "doc.shortcut_version_save",
"cli_path": "doc +version-save"
},
{
"canonical_path": "doc.delete_document_block",
"cli_path": "doc block delete"
@@ -121,6 +189,26 @@
"canonical_path": "doc.search_documents",
"cli_path": "doc search"
},
{
"canonical_path": "doc.style_background_clear",
"cli_path": "doc style background clear"
},
{
"canonical_path": "doc.style_background_set",
"cli_path": "doc style background set"
},
{
"canonical_path": "doc.style_cover_clear",
"cli_path": "doc style cover clear"
},
{
"canonical_path": "doc.style_cover_set",
"cli_path": "doc style cover set"
},
{
"canonical_path": "doc.get_document_style",
"cli_path": "doc style get"
},
{
"canonical_path": "doc.template_apply",
"cli_path": "doc template apply"
@@ -152,74 +240,6 @@
{
"canonical_path": "doc.version_save",
"cli_path": "doc version save"
},
{
"canonical_path": "doc.shortcut_search",
"cli_path": "doc +search"
},
{
"canonical_path": "doc.shortcut_list",
"cli_path": "doc +list"
},
{
"canonical_path": "doc.shortcut_copy",
"cli_path": "doc +copy"
},
{
"canonical_path": "doc.shortcut_move",
"cli_path": "doc +move"
},
{
"canonical_path": "doc.shortcut_comment_list",
"cli_path": "doc +comment-list"
},
{
"canonical_path": "doc.shortcut_comment_create",
"cli_path": "doc +comment-create"
},
{
"canonical_path": "doc.shortcut_comment_reply",
"cli_path": "doc +comment-reply"
},
{
"canonical_path": "doc.shortcut_export_submit",
"cli_path": "doc +export-submit"
},
{
"canonical_path": "doc.shortcut_export_get",
"cli_path": "doc +export-get"
},
{
"canonical_path": "doc.shortcut_version_save",
"cli_path": "doc +version-save"
},
{
"canonical_path": "doc.shortcut_version_list",
"cli_path": "doc +version-list"
},
{
"canonical_path": "doc.shortcut_version_revert",
"cli_path": "doc +version-revert"
},
{
"canonical_path": "doc.shortcut_template_list",
"cli_path": "doc +template-list"
},
{
"canonical_path": "doc.shortcut_template_search",
"cli_path": "doc +template-search"
},
{
"canonical_path": "doc.shortcut_doc_append",
"cli_path": "doc +doc-append"
},
{
"canonical_path": "doc.shortcut_find_doc",
"cli_path": "doc +find-doc"
},
{
"canonical_path": "doc.shortcut_share_doc",
"cli_path": "doc +share-doc"
}
]
}
@@ -1,6 +1,34 @@
{
"id": "drive",
"tools": [
{
"canonical_path": "drive.shortcut_copy",
"cli_path": "drive +copy"
},
{
"canonical_path": "drive.shortcut_find_file",
"cli_path": "drive +find-file"
},
{
"canonical_path": "drive.shortcut_info",
"cli_path": "drive +info"
},
{
"canonical_path": "drive.shortcut_move",
"cli_path": "drive +move"
},
{
"canonical_path": "drive.shortcut_recent",
"cli_path": "drive +recent"
},
{
"canonical_path": "drive.shortcut_search",
"cli_path": "drive +search"
},
{
"canonical_path": "drive.shortcut_search_docs",
"cli_path": "drive +search-docs"
},
{
"canonical_path": "drive.commit_upload",
"cli_path": "drive commit"
@@ -9,6 +37,10 @@
"canonical_path": "drive.copy_document",
"cli_path": "drive copy"
},
{
"canonical_path": "drive.get_cover",
"cli_path": "drive cover"
},
{
"canonical_path": "drive.delete_document",
"cli_path": "drive delete"
@@ -17,6 +49,10 @@
"canonical_path": "drive.download_file",
"cli_path": "drive download"
},
{
"canonical_path": "drive.download_file_version",
"cli_path": "drive download-version"
},
{
"canonical_path": "drive.get_file_info",
"cli_path": "drive info"
@@ -41,6 +77,14 @@
"canonical_path": "drive.add_permission",
"cli_path": "drive permission add"
},
{
"canonical_path": "drive.apply_permission",
"cli_path": "drive permission apply"
},
{
"canonical_path": "drive.query_permission_apply_info",
"cli_path": "drive permission apply-info"
},
{
"canonical_path": "drive.list_permission",
"cli_path": "drive permission list"
@@ -49,6 +93,10 @@
"canonical_path": "drive.permission_remove",
"cli_path": "drive permission remove"
},
{
"canonical_path": "drive.transfer_owner",
"cli_path": "drive permission transfer-owner"
},
{
"canonical_path": "drive.permission_update",
"cli_path": "drive permission update"
@@ -81,6 +129,10 @@
"canonical_path": "drive.rename_document",
"cli_path": "drive rename"
},
{
"canonical_path": "drive.revert_file_version",
"cli_path": "drive revert"
},
{
"canonical_path": "drive.search_files",
"cli_path": "drive search"
@@ -89,6 +141,18 @@
"canonical_path": "drive.create_shortcut",
"cli_path": "drive shortcut"
},
{
"canonical_path": "drive.mark_star",
"cli_path": "drive star add"
},
{
"canonical_path": "drive.get_star_list",
"cli_path": "drive star list"
},
{
"canonical_path": "drive.unmark_star",
"cli_path": "drive star remove"
},
{
"canonical_path": "drive.get_node_stats",
"cli_path": "drive stats"
@@ -100,34 +164,6 @@
{
"canonical_path": "drive.get_upload_info",
"cli_path": "drive upload-info"
},
{
"canonical_path": "drive.shortcut_info",
"cli_path": "drive +info"
},
{
"canonical_path": "drive.shortcut_search",
"cli_path": "drive +search"
},
{
"canonical_path": "drive.shortcut_search_docs",
"cli_path": "drive +search-docs"
},
{
"canonical_path": "drive.shortcut_copy",
"cli_path": "drive +copy"
},
{
"canonical_path": "drive.shortcut_move",
"cli_path": "drive +move"
},
{
"canonical_path": "drive.shortcut_recent",
"cli_path": "drive +recent"
},
{
"canonical_path": "drive.shortcut_find_file",
"cli_path": "drive +find-file"
}
]
}
@@ -1,6 +1,14 @@
{
"id": "sheet",
"tools": [
{
"canonical_path": "sheet.shortcut_list_sheets",
"cli_path": "sheet +list-sheets"
},
{
"canonical_path": "sheet.shortcut_read",
"cli_path": "sheet +read"
},
{
"canonical_path": "sheet.add_dimension",
"cli_path": "sheet add-dimension"
@@ -29,6 +37,26 @@
"canonical_path": "sheet.chart_update",
"cli_path": "sheet chart update"
},
{
"canonical_path": "sheet.create_sheet_comment",
"cli_path": "sheet comment create"
},
{
"canonical_path": "sheet.delete_sheet_comment",
"cli_path": "sheet comment delete"
},
{
"canonical_path": "sheet.list_sheet_comments",
"cli_path": "sheet comment list"
},
{
"canonical_path": "sheet.reply_sheet_comment",
"cli_path": "sheet comment reply"
},
{
"canonical_path": "sheet.update_sheet_comment",
"cli_path": "sheet comment update"
},
{
"canonical_path": "sheet.create_cond_format",
"cli_path": "sheet cond-format create"
@@ -149,6 +177,10 @@
"canonical_path": "sheet.find_cells",
"cli_path": "sheet find"
},
{
"canonical_path": "sheet.formula_verify",
"cli_path": "sheet formula-verify"
},
{
"canonical_path": "sheet.get_dropdown_lists",
"cli_path": "sheet get-dropdown"
@@ -165,10 +197,6 @@
"canonical_path": "sheet.hide_gridline",
"cli_path": "sheet hide-gridline"
},
{
"canonical_path": "sheet.info",
"cli_path": "sheet info"
},
{
"canonical_path": "sheet.import",
"cli_path": "sheet import create"
@@ -177,6 +205,10 @@
"canonical_path": "sheet.import_get",
"cli_path": "sheet import get"
},
{
"canonical_path": "sheet.info",
"cli_path": "sheet info"
},
{
"canonical_path": "sheet.insert_dimension",
"cli_path": "sheet insert-dimension"
@@ -319,17 +351,21 @@
"canonical_path": "sheet.update_float_image",
"cli_path": "sheet update-float-image"
},
{
"canonical_path": "sheet.version_list",
"cli_path": "sheet version list"
},
{
"canonical_path": "sheet.version_revert",
"cli_path": "sheet version revert"
},
{
"canonical_path": "sheet.version_save",
"cli_path": "sheet version save"
},
{
"canonical_path": "sheet.write_image",
"cli_path": "sheet write-image"
},
{
"canonical_path": "sheet.shortcut_list_sheets",
"cli_path": "sheet +list-sheets"
},
{
"canonical_path": "sheet.shortcut_read",
"cli_path": "sheet +read"
}
]
}
@@ -40,6 +40,7 @@ func init() {
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
+168 -2
View File
@@ -1152,6 +1152,32 @@
"cli_path": "chat +chat-role-query-user",
"runtime_gate": "none"
},
"chat.shortcut_chat_messages": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"reviewed": true,
"review_reason": "Reviewed against the executable Shortcut and group/direct lower read tools. Optional downloads stay read/not_required under the existing safe local-download convention: workspace-relative paths, no overwrite by default, and atomic publication.",
"cli_path": "chat +chat-messages",
"runtime_gate": "none"
},
"chat.shortcut_messages_send": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against every executable identity/content/target validation branch, authenticated contact-search response shape, and the real lower tool mappings; bot and webhook media are intentionally rejected because no equivalent lower capability exists.",
"cli_path": "chat +messages-send",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_by_webhook": {
"effect": "write",
"risk": "medium",
@@ -1165,6 +1191,25 @@
"cli_path": "chat +messages-send-by-webhook",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_card": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
"parameters": {
"receiver-open-dingtalk-id": {
"property": "receiverOpenDingTalkId",
"required": false
}
},
"reviewed": true,
"review_reason": "Reviewed against authenticated contact-search resolution, create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId; the explicit openDingTalkId flag is bound to the lower interface's exact receiverOpenDingTalkId property.",
"cli_path": "chat +messages-send-card",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_list_direct": {
"effect": "read",
"risk": "low",
@@ -1200,7 +1245,7 @@
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +messages-mget",
"runtime_gate": "none"
},
@@ -1265,7 +1310,7 @@
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +at-me",
"runtime_gate": "none"
},
@@ -1321,6 +1366,19 @@
"cli_path": "chat +my-groups",
"runtime_gate": "none"
},
"chat.shortcut_search_msg": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"reviewed": true,
"review_reason": "Reviewed against the executable advanced-search flags plus page and enrichment ledgers. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +search-msg",
"runtime_gate": "none"
},
"chat.shortcut_send_to_group": {
"effect": "write",
"risk": "medium",
@@ -1334,6 +1392,19 @@
"cli_path": "chat +send-to-group",
"runtime_gate": "typed_yes"
},
"chat.shortcut_thread_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"reviewed": true,
"review_reason": "Reviewed against the executable thread/topic alias contract and real list_topic_replies mapping. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +thread-replies",
"runtime_gate": "none"
},
"chat.shortcut_unread_chats": {
"effect": "read",
"risk": "low",
@@ -1346,6 +1417,101 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "chat +unread-chats",
"runtime_gate": "none"
},
"chat.update_text_emotion": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI calls im/update_text_emotion, which is proven by dws-wukong develop@30f13f02 but absent from the pinned MCP metadata snapshot; no pinned interface_ref can represent the command yet.",
"reviewed": true,
"parameters": {
"background-id": {
"property": "backgroundId",
"description": "新文字表情的背景 ID",
"required": true
},
"chat": {
"property": "openConversationId",
"required": false
},
"conversation-id": {
"property": "openConversationId",
"description": "会话 openConversationId;与 --group、--id、--chat 四选一",
"required": false
},
"emotion-id": {
"property": "emotionId",
"description": "新的文字表情 ID,可通过 create-text-emotion 获取",
"required": true
},
"emotion-name": {
"property": "emotionName",
"description": "新的文字表情名称",
"required": true
},
"group": {
"property": "openConversationId",
"required": false
},
"id": {
"property": "openConversationId",
"required": false
},
"msg-id": {
"property": "openMsgId",
"description": "需要原地更新文字表情的消息 openMsgId",
"required": true
},
"old-emotion-id": {
"property": "oldEmotionId",
"description": "消息上当前文字表情的 emotionId",
"required": true
},
"text": {
"property": "text",
"description": "新的文字表情内容",
"required": true
}
},
"review_reason": "Ported from dws-wukong commits d6831318 and bcf56ccf, merged at 30f13f02. The four conversation locators alias one property; the other six business parameters are required by Cobra before the single im/update_text_emotion call.",
"cli_path": "chat message update-text-emotion",
"runtime_gate": "none"
},
"chat.get_group_mute_config": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"chat.batch_query_group_chat_settings": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"chat.batch_update_group_chat_settings": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
}
}
}
@@ -587,6 +587,61 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "doc +share-doc",
"runtime_gate": "typed_yes"
},
"doc.style_cover_set": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_cover_clear": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_background_set": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_background_clear": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.get_document_style": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
}
}
}
+141 -42
View File
@@ -17,6 +17,17 @@
"availability": "available",
"reviewed": true
},
"drive.apply_permission": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"drive.commit_upload": {
"interface_mode": "mcp",
"availability": "available",
@@ -64,6 +75,28 @@
"availability": "available",
"reviewed": true
},
"drive.download_file_version": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_cover": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_file_info": {
"interface_mode": "mcp",
"availability": "available",
@@ -75,6 +108,17 @@
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true
},
"drive.get_star_list": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_upload_info": {
"interface_mode": "mcp",
"availability": "available",
@@ -100,6 +144,17 @@
"availability": "available",
"reviewed": true
},
"drive.mark_star": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.move_document": {
"interface_ref": {
"product_id": "doc",
@@ -164,6 +219,17 @@
"reviewed": true,
"runtime_gate": "confirm_dangerous"
},
"drive.query_permission_apply_info": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.recent": {
"effect": "read",
"risk": "low",
@@ -212,28 +278,46 @@
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"cli_path": "drive rename"
},
"drive.revert_file_version": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"drive.search_files": {
"interface_mode": "mcp",
"availability": "available",
"reviewed": true
},
"drive.upload": {
"drive.shortcut_copy": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
"interface_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.",
"reviewed": true,
"parameters": {
"node": {
"property": "nodeId",
"required": false
}
},
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
"cli_path": "drive upload",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +copy",
"runtime_gate": "typed_yes"
},
"drive.shortcut_find_file": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +find-file",
"runtime_gate": "none"
},
"drive.shortcut_info": {
@@ -249,6 +333,32 @@
"cli_path": "drive +info",
"runtime_gate": "none"
},
"drive.shortcut_move": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +move",
"runtime_gate": "typed_yes"
},
"drive.shortcut_recent": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +recent",
"runtime_gate": "none"
},
"drive.shortcut_search": {
"effect": "read",
"risk": "low",
@@ -275,56 +385,45 @@
"cli_path": "drive +search-docs",
"runtime_gate": "none"
},
"drive.shortcut_copy": {
"drive.transfer_owner": {
"effect": "write",
"risk": "medium",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +copy",
"runtime_gate": "typed_yes"
"runtime_gate": "confirm_delete"
},
"drive.shortcut_move": {
"drive.unmark_star": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +move",
"runtime_gate": "typed_yes"
},
"drive.shortcut_recent": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +recent",
"runtime_gate": "none"
},
"drive.shortcut_find_file": {
"effect": "read",
"risk": "low",
"drive.upload": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_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.",
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +find-file",
"parameters": {
"node": {
"property": "nodeId",
"required": false
}
},
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
"cli_path": "drive upload",
"runtime_gate": "none"
}
}
@@ -734,6 +734,105 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "sheet +read",
"runtime_gate": "none"
},
"sheet.version_save": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.version_list": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.version_revert": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"sheet.formula_verify": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.list_sheet_comments": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.create_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.reply_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.update_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.delete_sheet_comment": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
}
}
}
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 813,
"source_tools": 845,
"matched_tools": 71
},
"tools": {
+183 -2
View File
@@ -2110,6 +2110,46 @@
"ShortcutRegistry:chat +chat-role-query-user"
]
},
"chat.shortcut_chat_messages": {
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified group/direct routing, stable projection, pagination, and optional safe resource workflow; it distinguishes conversation history from cross-conversation search and exact-ID mget.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_chat_messages",
"cobra-help:dws chat +chat-messages",
"ShortcutRegistry:chat +chat-messages"
]
},
"chat.shortcut_messages_send": {
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
],
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"examples": [
"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"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified identity/content capability matrix, live-compatible contact search with exact userId matching, native local-file upload flow, and fail-closed rejection of unsupported bot/webhook media.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send",
"cobra-help:dws chat +messages-send",
"ShortcutRegistry:chat +messages-send"
]
},
"chat.shortcut_messages_send_by_webhook": {
"agent_summary": "自定义机器人 Webhook 发送群消息",
"use_when": [
@@ -2129,6 +2169,26 @@
"ShortcutRegistry:chat +messages-send-by-webhook"
]
},
"chat.shortcut_messages_send_card": {
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 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 完成,避免卡片停留在加载中。"
],
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"examples": [
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified create-only and create-then-update lifecycle, live-compatible contact search with exact userId matching, explicit userId/openDingTalkId routing, two-action dry-run, bizId extraction, and partial-failure reporting.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send_card",
"cobra-help:dws chat +messages-send-card",
"ShortcutRegistry:chat +messages-send-card"
]
},
"chat.shortcut_messages_list_direct": {
"agent_summary": "拉取单聊会话消息",
"use_when": [
@@ -2170,7 +2230,7 @@
"chat.shortcut_messages_mget": {
"agent_summary": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2265,7 +2325,7 @@
"chat.shortcut_at_me": {
"agent_summary": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2359,6 +2419,26 @@
"ShortcutRegistry:chat +my-groups"
]
},
"chat.shortcut_search_msg": {
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified filter mapping, cursor pagination, batched mget enrichment, completeness ledger, and shared safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_search_msg",
"cobra-help:dws chat +search-msg",
"ShortcutRegistry:chat +search-msg"
]
},
"chat.shortcut_send_to_group": {
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
"use_when": [
@@ -2378,6 +2458,26 @@
"ShortcutRegistry:chat +send-to-group"
]
},
"chat.shortcut_thread_replies": {
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
],
"examples": [
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified thread/topic alias, lower list_topic_replies mapping, stable projection, group-context fallback, and optional safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_thread_replies",
"cobra-help:dws chat +thread-replies",
"ShortcutRegistry:chat +thread-replies"
]
},
"chat.shortcut_unread_chats": {
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
"use_when": [
@@ -2397,6 +2497,87 @@
"cobra-help:dws chat +unread-chats",
"ShortcutRegistry:chat +unread-chats"
]
},
"chat.update_text_emotion": {
"agent_summary": "把消息上已有的文字表情原地替换为新的文字表情",
"use_when": [
"需要更新消息的状态文字或表情,并避免先移除再添加造成闪烁和两次网络调用时"
],
"avoid_when": [
"消息上还没有文字表情时使用 chat message add-text-emotion",
"只需清除文字表情时使用 chat message remove-text-emotion"
],
"examples": [
"dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMessageId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"
],
"reviewed": true,
"review_reason": "依据 issue #85 的原地状态更新场景、Wukong 30f13f02 的真实 Cobra/MCP 映射和开源实现复核;选择语义不改变执行、安全或参数事实。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_text_emotion",
"cobra-help:dws chat message update-text-emotion",
"dws-wukong:d6831318",
"dws-wukong:bcf56ccf",
"dws-wukong:30f13f02",
"github:hugozhu/dingtalk-opencode-tag#85",
"live-dws-schema:im.update_text_emotion#FAILED_NOT_AUTHENTICATED"
]
},
"chat.get_group_mute_config": {
"agent_summary": "查询群用户禁言配置(禁言黑名单/全员禁言白名单)",
"use_when": [
"用户说 看下群里谁被禁言/禁言配置"
],
"avoid_when": [
"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"
],
"examples": [
"dws chat group get-mute-config --group <openConversationId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.get_group_mute_config",
"cobra-help:dws chat group get-mute-config",
"wukong-develop:wukong/products"
]
},
"chat.batch_query_group_chat_settings": {
"agent_summary": "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"use_when": [
"用户说 看下这些群我的置顶和免打扰设置"
],
"avoid_when": [
"管理员级群功能开关用 chat group update-settings"
],
"examples": [
"dws chat group user-settings query --groups cid1,cid2 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.batch_query_group_chat_settings",
"cobra-help:dws chat group user-settings query",
"wukong-develop:wukong/products"
]
},
"chat.batch_update_group_chat_settings": {
"agent_summary": "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"use_when": [
"用户说 把这些群都设为免打扰/置顶"
],
"avoid_when": [
"单个群昵称优先 chat group update-nick"
],
"examples": [
"dws chat group user-settings set --items '[{\"openConversationId\":\"cid1\",\"top\":true,\"mute\":false}]' --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.batch_update_group_chat_settings",
"cobra-help:dws chat group user-settings set",
"wukong-develop:wukong/products"
]
}
},
"products": {
@@ -1226,6 +1226,101 @@
"cobra-help:dws doc +share-doc",
"ShortcutRegistry:doc +share-doc"
]
},
"doc.style_cover_set": {
"agent_summary": "设置钉钉文档顶部封面图(外链或本地图片上传)",
"use_when": [
"用户说 给文档设置封面/换个封面图"
],
"avoid_when": [
"读取当前封面用 doc style get;移除封面用 cover clear"
],
"examples": [
"dws doc style cover set --node <DOC_ID> --image https://img.example.com/cover.png --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_cover_set",
"cobra-help:dws doc style cover set",
"wukong-develop:wukong/products"
]
},
"doc.style_cover_clear": {
"agent_summary": "移除钉钉文档封面",
"use_when": [
"用户说 去掉文档封面"
],
"avoid_when": [
"设置封面用 cover set"
],
"examples": [
"dws doc style cover clear --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_cover_clear",
"cobra-help:dws doc style cover clear",
"wukong-develop:wukong/products"
]
},
"doc.style_background_set": {
"agent_summary": "设置钉钉文档背景纯色(#RRGGBB)",
"use_when": [
"用户说 给文档设置背景色"
],
"avoid_when": [
"背景不支持图片;封面用 cover set"
],
"examples": [
"dws doc style background set --node <DOC_ID> --color \"#E8F2FE\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_background_set",
"cobra-help:dws doc style background set",
"wukong-develop:wukong/products"
]
},
"doc.style_background_clear": {
"agent_summary": "清除钉钉文档背景",
"use_when": [
"用户说 去掉文档背景色"
],
"avoid_when": [
"设置背景用 background set"
],
"examples": [
"dws doc style background clear --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_background_clear",
"cobra-help:dws doc style background clear",
"wukong-develop:wukong/products"
]
},
"doc.get_document_style": {
"agent_summary": "读取文档当前封面与背景配置(只读)",
"use_when": [
"用户说 看下文档现在的封面/背景"
],
"avoid_when": [
"修改用 cover set / background set"
],
"examples": [
"dws doc style get --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.get_document_style",
"cobra-help:dws doc style get",
"wukong-develop:wukong/products"
]
}
},
"products": {
+259 -84
View File
@@ -33,6 +33,25 @@
"live-mcp:dws schema doc.add_permission"
]
},
"drive.apply_permission": {
"agent_summary": "向审批人发起文档权限申请(会真实通知审批人)",
"use_when": [
"用户确认要为无权限文档发起权限申请"
],
"avoid_when": [
"先用 apply-info 查可申请角色与审批人;未经用户确认不得自行提交"
],
"examples": [
"dws drive permission apply --node <DOC_ID> --role READER --users <审批人userId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.apply_permission",
"cobra-help:dws drive permission apply",
"wukong-develop:wukong/products"
]
},
"drive.commit_upload": {
"agent_summary": "提交文件上传",
"use_when": [
@@ -187,6 +206,47 @@
"live-mcp:dws schema drive.download_file"
]
},
"drive.download_file_version": {
"agent_summary": "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
"use_when": [
"用户要下载文件的历史版本/旧版本",
"版本号已通过 drive list --versions 获取"
],
"avoid_when": [
"下载最新版本用 drive download",
"在线文档(adoc)历史版本用 doc version 系列命令",
"在线表格(axls)历史版本用 sheet version 系列命令"
],
"examples": [
"dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop drive download --version 分支实现移植为独立命令;单一 download_file_version 远端调用,参数契约与帮助文档一致。",
"source_refs": [
"CommandRegistry:canonical_path=drive.download_file_version",
"cobra-help:dws drive download-version",
"wukong-develop:wukong/products/drive.go"
]
},
"drive.get_cover": {
"agent_summary": "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
"use_when": [
"用户说 封面/封面图/缩略图/预览图"
],
"avoid_when": [
"设置文档封面用 doc style cover set"
],
"examples": [
"dws drive cover --node <dentryUuid> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.get_cover",
"cobra-help:dws drive cover",
"wukong-develop:wukong/products"
]
},
"drive.get_file_info": {
"agent_summary": "获取文件元数据信息",
"use_when": [
@@ -235,6 +295,26 @@
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"drive.get_star_list": {
"agent_summary": "获取当前用户的收藏列表,支持分页与按内容类型筛选",
"use_when": [
"用户说 我的收藏/收藏列表/收藏了哪些文档"
],
"avoid_when": [
"操作单个收藏用 mark_star/unmark_star"
],
"examples": [
"dws drive star list --format json",
"dws drive star list --content-types doc,sheet --limit 10 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.get_star_list",
"cobra-help:dws drive star list",
"wukong-develop:wukong/products"
]
},
"drive.get_upload_info": {
"agent_summary": "获取文件上传信息",
"use_when": [
@@ -334,6 +414,25 @@
"live-mcp:dws schema drive.list_spaces"
]
},
"drive.mark_star": {
"agent_summary": "收藏文档/文件到当前用户收藏列表",
"use_when": [
"用户说 收藏这个文档/加个收藏/标星"
],
"avoid_when": [
"取消收藏用 unmark_star;查看收藏列表用 get_star_list"
],
"examples": [
"dws drive star add --node <nodeId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.mark_star",
"cobra-help:dws drive star add",
"wukong-develop:wukong/products"
]
},
"drive.move_document": {
"agent_summary": "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
"use_when": [
@@ -475,6 +574,25 @@
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"drive.query_permission_apply_info": {
"agent_summary": "查询节点可申请的权限角色列表与审批人列表",
"use_when": [
"无权限访问文档时,先查可申请角色与审批人"
],
"avoid_when": [
"实际发起申请用 apply_permission"
],
"examples": [
"dws drive permission apply-info --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.query_permission_apply_info",
"cobra-help:dws drive permission apply-info",
"wukong-develop:wukong/products"
]
},
"drive.recent": {
"agent_summary": "获取当前用户最近访问或编辑过的文档列表",
"use_when": [
@@ -568,6 +686,25 @@
"live-mcp:dws schema doc.rename_document"
]
},
"drive.revert_file_version": {
"agent_summary": "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
"use_when": [
"用户说 回滚版本/恢复到某个版本/版本回退,且目标是普通文件"
],
"avoid_when": [
"在线文档回滚用 doc version revert;在线表格用 sheet version revert"
],
"examples": [
"dws drive revert --node <dentryUuid> --version 3 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.revert_file_version",
"cobra-help:dws drive revert",
"wukong-develop:wukong/products"
]
},
"drive.search_files": {
"agent_summary": "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
"use_when": [
@@ -595,32 +732,43 @@
"live-mcp:dws schema drive.search_files"
]
},
"drive.upload": {
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
"drive.shortcut_copy": {
"agent_summary": "复制文件/文档到指定位置",
"use_when": [
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
],
"avoid_when": [
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
"要把文件作为文档正文附件插入改用 dws doc media insert",
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive upload --file ./report.pdf --format json",
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
],
"reviewed": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"CommandRegistry:canonical_path=drive.upload",
"cobra-help:dws drive upload",
"Skill:skills/mono/references/products/drive.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
"structured-hint:internal/cli/schema_hints/products/drive.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
"cobra-help:dws drive +copy",
"ShortcutRegistry:drive +copy"
]
},
"drive.shortcut_find_file": {
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
"use_when": [
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +find-file --query 季度汇报",
"dws drive +find-file --query 合同"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
"cobra-help:dws drive +find-file",
"ShortcutRegistry:drive +find-file"
]
},
"drive.shortcut_info": {
@@ -642,64 +790,6 @@
"ShortcutRegistry:drive +info"
]
},
"drive.shortcut_search": {
"agent_summary": "搜索钉盘文件",
"use_when": [
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search --query \"季度汇报\"",
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search",
"cobra-help:dws drive +search",
"ShortcutRegistry:drive +search"
]
},
"drive.shortcut_search_docs": {
"agent_summary": "搜索文档空间文档",
"use_when": [
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search-docs --query \"季度汇报\""
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
"cobra-help:dws drive +search-docs",
"ShortcutRegistry:drive +search-docs"
]
},
"drive.shortcut_copy": {
"agent_summary": "复制文件/文档到指定位置",
"use_when": [
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
"cobra-help:dws drive +copy",
"ShortcutRegistry:drive +copy"
]
},
"drive.shortcut_move": {
"agent_summary": "移动文件/文档到指定位置",
"use_when": [
@@ -739,24 +829,109 @@
"ShortcutRegistry:drive +recent"
]
},
"drive.shortcut_find_file": {
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
"drive.shortcut_search": {
"agent_summary": "搜索钉盘文件",
"use_when": [
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +find-file --query 季度汇报",
"dws drive +find-file --query 合同"
"dws drive +search --query \"季度汇报\"",
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
"cobra-help:dws drive +find-file",
"ShortcutRegistry:drive +find-file"
"internal/cli/schema_command_registry.json#drive.shortcut_search",
"cobra-help:dws drive +search",
"ShortcutRegistry:drive +search"
]
},
"drive.shortcut_search_docs": {
"agent_summary": "搜索文档空间文档",
"use_when": [
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search-docs --query \"季度汇报\""
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
"cobra-help:dws drive +search-docs",
"ShortcutRegistry:drive +search-docs"
]
},
"drive.transfer_owner": {
"agent_summary": "转交文档或知识库所有者给指定用户(不可逆)",
"use_when": [
"用户明确要求转交文档/知识库所有权"
],
"avoid_when": [
"普通协作权限变更用 permission add/update/remove"
],
"examples": [
"dws drive permission transfer-owner --node <DOC_ID> --new-owner <userId> --reserve-role EDITOR --recursive=false --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.transfer_owner",
"cobra-help:dws drive permission transfer-owner",
"wukong-develop:wukong/products"
]
},
"drive.unmark_star": {
"agent_summary": "将文档/文件从当前用户收藏列表移除",
"use_when": [
"用户说 取消收藏/去掉收藏/不收藏了"
],
"avoid_when": [
"添加收藏用 mark_star"
],
"examples": [
"dws drive star remove --node <nodeId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.unmark_star",
"cobra-help:dws drive star remove",
"wukong-develop:wukong/products"
]
},
"drive.upload": {
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
"use_when": [
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
],
"avoid_when": [
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
"要把文件作为文档正文附件插入改用 dws doc media insert",
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
],
"examples": [
"dws drive upload --file ./report.pdf --format json",
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.upload",
"cobra-help:dws drive upload",
"Skill:skills/mono/references/products/drive.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
"structured-hint:internal/cli/schema_hints/products/drive.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
}
},
@@ -1629,6 +1629,177 @@
"cobra-help:dws sheet +read",
"ShortcutRegistry:sheet +read"
]
},
"sheet.version_save": {
"agent_summary": "手动保存表格版本快照",
"use_when": [
"用户说 保存版本/存个快照,目标是在线表格"
],
"avoid_when": [
"回滚用 version revert;查看历史用 version list"
],
"examples": [
"dws sheet version save --node <SHEET_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_save",
"cobra-help:dws sheet version save",
"wukong-develop:wukong/products"
]
},
"sheet.version_list": {
"agent_summary": "查看表格历史版本列表",
"use_when": [
"用户说 看历史版本/版本列表,目标是在线表格"
],
"avoid_when": [
"回滚用 version revert"
],
"examples": [
"dws sheet version list --node <SHEET_ID> --limit 10 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_list",
"cobra-help:dws sheet version list",
"wukong-develop:wukong/products"
]
},
"sheet.version_revert": {
"agent_summary": "回滚表格到指定历史版本",
"use_when": [
"用户说 回滚到某个版本/恢复到之前的表格"
],
"avoid_when": [
"普通文件回滚用 drive revert;在线文档用 doc version revert"
],
"examples": [
"dws sheet version revert --node <SHEET_ID> --version 3 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_revert",
"cobra-help:dws sheet version revert",
"wukong-develop:wukong/products"
]
},
"sheet.formula_verify": {
"agent_summary": "扫描表格公式单元格并按错误类型聚合返回错误数量与位置",
"use_when": [
"用户说 校验公式/检查公式错误/公式错误扫描"
],
"avoid_when": [
"读取公式文本用 range read --value-render-option formula"
],
"examples": [
"dws sheet formula-verify --node <NODE_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.formula_verify",
"cobra-help:dws sheet formula-verify",
"wukong-develop:wukong/products"
]
},
"sheet.list_sheet_comments": {
"agent_summary": "查询表格单元格评论列表,支持分页与按解决状态过滤",
"use_when": [
"用户说 看某格的评论/表格里有哪些批注"
],
"avoid_when": [
"新建评论用 comment create"
],
"examples": [
"dws sheet comment list --node <SHEET_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.list_sheet_comments",
"cobra-help:dws sheet comment list",
"wukong-develop:wukong/products"
]
},
"sheet.create_sheet_comment": {
"agent_summary": "在指定单元格上创建评论,可 @ 用户",
"use_when": [
"用户说 给单元格加评论/批注/@某人讨论这个数据"
],
"avoid_when": [
"回复已有评论用 comment reply;不要把评论写进单元格值"
],
"examples": [
"dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content \"这个数字有问题\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.create_sheet_comment",
"cobra-help:dws sheet comment create",
"wukong-develop:wukong/products"
]
},
"sheet.reply_sheet_comment": {
"agent_summary": "回复指定单元格评论,支持表情贴图回复",
"use_when": [
"用户说 回复这条评论"
],
"avoid_when": [
"修改评论内容用 comment update"
],
"examples": [
"dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已核实\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.reply_sheet_comment",
"cobra-help:dws sheet comment reply",
"wukong-develop:wukong/products"
]
},
"sheet.update_sheet_comment": {
"agent_summary": "更新单元格评论内容",
"use_when": [
"用户说 改一下这条评论"
],
"avoid_when": [
"回复评论用 comment reply"
],
"examples": [
"dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已修正\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.update_sheet_comment",
"cobra-help:dws sheet comment update",
"wukong-develop:wukong/products"
]
},
"sheet.delete_sheet_comment": {
"agent_summary": "删除单元格评论(不可恢复)",
"use_when": [
"用户明确要求删除某条评论"
],
"avoid_when": [
"标记解决不等于删除"
],
"examples": [
"dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.delete_sheet_comment",
"cobra-help:dws sheet comment delete",
"wukong-develop:wukong/products"
]
}
},
"products": {
+85 -4
View File
@@ -2,8 +2,8 @@
"version": 3,
"baseline": {
"manifest": "schema-parameter-bindings-v3",
"sha256": "sha256:f48d7b55b6ca9719c466475ee32d678eab0deaee1d79ac4e4790c7ee441ebe7b",
"reason": "Reviewed v3 baseline after exposing the pinned chat.search_messages messageType, onlyRobotMessages, and searchConvType properties through exact Cobra flags.",
"sha256": "sha256:ff0f76145dd27da5429434a348344c9cdf74c44eaf6f4ea5411d29d904a9066f",
"reason": "Reviewed v3 baseline after binding sheet.info --include to the include property while porting the wukong sheet info expansion flag.",
"reviewed": true
},
"removals": {
@@ -43,6 +43,10 @@
"reason": "The public pagination flag was normalized from --size to --limit.",
"replaced_by": "oa.list_user_visible_process --limit",
"reviewed": true
},
"drive.download_file --version": {
"reason": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.",
"reviewed": true
}
},
"bindings": {
@@ -1108,7 +1112,8 @@
"node": "nodeId"
},
"sheet.info": {
"node": "nodeId"
"node": "nodeId",
"include": "include"
},
"sheet.insert_dimension": {
"node": "nodeId"
@@ -1590,7 +1595,10 @@
"attendance.vacation_update_type --when-can-leave": "Reviewed unpinned adapter: attendance.vacation_update_type has no singular pinned interface_ref; --when-can-leave is a CLI wrapper input and does not publish a direct interface property.",
"chat.add_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.add_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
@@ -1607,7 +1615,12 @@
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
"chat.send_personal_message --file-path": "local upload/preprocessing input",
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
"chat.send_personal_message --text": "serialized into the aggregate content payload",
"chat.send_personal_message --title": "serialized into the aggregate content payload",
@@ -1632,8 +1645,8 @@
"dev.disable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.disable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.enable_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.enable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --app-key": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --app-key is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_credentials --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_credentials has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_version_detail --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_version_detail --version-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --version-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -1728,6 +1741,7 @@
"doc.get_document_content --scope": "Runtime extension sends scope for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --start-block-id": "Runtime extension sends startBlockId for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --tags": "Runtime extension sends tags for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_style --node": "Reviewed unpinned adapter: doc.get_document_style has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --task-id": "Reviewed unpinned adapter: doc.import_get has no singular pinned interface_ref; --task-id is a CLI wrapper input and does not publish a direct interface property.",
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
@@ -1735,6 +1749,14 @@
"doc.insert_document_block --text": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --node": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_clear --node": "Reviewed unpinned adapter: doc.style_cover_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --file": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --file is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --image": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --image is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --node": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --position": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --position is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --folder": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --folder is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --name": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --name is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --template-id": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --template-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -1763,7 +1785,22 @@
"doc.version_revert --node": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_revert --version": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_save --node": "Reviewed unpinned adapter: doc.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --node": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --notify-mode": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --notify-mode is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file --output": "local output path",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --cursor": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --limit": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --order-by": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --order-by is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --resource-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --resource-types is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --sort": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --sort is a CLI wrapper input and does not publish a direct interface property.",
"drive.list_files --cursor": "composite route maps to nextToken for drive.list_files or pageToken for doc.list_nodes",
"drive.list_files --folder": "composite route maps to parentId for drive.list_files or folderId for doc.list_nodes",
"drive.list_files --limit": "composite route maps to maxResults for drive.list_files or pageSize for doc.list_nodes",
@@ -1772,10 +1809,12 @@
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --permission": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --permission is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_unset --node": "Reviewed unpinned adapter: drive.publish_unset has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.query_permission_apply_info --node": "Reviewed unpinned adapter: drive.query_permission_apply_info has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --creator-type": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --creator-type is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --cursor": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --file-types": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --file-types is a CLI wrapper input and does not publish a direct interface property.",
@@ -1786,6 +1825,14 @@
"drive.recycle_list --limit": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"drive.recycle_list --space-id": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --space-id is a CLI wrapper input and does not publish a direct interface property.",
"drive.recycle_restore --id": "Reviewed unpinned adapter: drive.recycle_restore has no singular pinned interface_ref; --id is a CLI wrapper input and does not publish a direct interface property.",
"drive.revert_file_version --node": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.revert_file_version --version": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --new-owner": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --new-owner is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --node": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --recursive": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --recursive is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --reserve-role": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --reserve-role is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --workspace": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --workspace is a CLI wrapper input and does not publish a direct interface property.",
"drive.unmark_star --node": "Reviewed unpinned adapter: drive.unmark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"mail.create_download_session --name": "local download filename",
"mail.create_download_session --output": "local output path",
"mail.create_draft --attachment": "reviewed local attachment preprocessing input",
@@ -1831,9 +1878,16 @@
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-sheet-id": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --content": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --mention": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_cond_format --yes": "local confirmation control",
"sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_sheet_comment --node": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_get_criteria --column": "local post-filter over get_filter_views response",
"sheet.filter_view_get_criteria --filter-view-id": "local post-filter over get_filter_views response",
"sheet.filter_view_info --filter-view-id": "local post-filter over get_filter_views response",
@@ -1843,6 +1897,13 @@
"sheet.filter_view_update_criteria --filter-view-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --filter-view-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_update_criteria --node": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_update_criteria --sheet-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --exit-on-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --exit-on-error is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --max-cells": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-cells is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --max-locations-per-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-locations-per-error is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --node": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --range": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --sheet-id": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --targets": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --targets is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --group-state": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --group-state is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --node": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --range": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
@@ -1850,6 +1911,12 @@
"sheet.hide_gridline --sheet-id": "Reviewed unpinned adapter: sheet.hide_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_pivot_tables --pivot-table-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_pivot_tables --sheet-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --cursor": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --limit": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --node": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --range": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --resolve-status": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --resolve-status is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --sheet-id": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
@@ -1857,6 +1924,11 @@
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.submit_export_job --output": "local destination consumed after export job completion",
"sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.",
@@ -1881,6 +1953,15 @@
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --content": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --node": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --cursor": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --limit": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --node": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_revert --node": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_revert --version": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_save --node": "Reviewed unpinned adapter: sheet.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
"sheet.write_image --mime-type": "local upload metadata",
"sheet.write_image --name": "local upload metadata",
+187 -4
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -289,6 +290,47 @@ func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string {
return text
}
// NormalizeMessageMentions applies the placeholder convention required by the
// selected sender identity and ensures a declared @all is present in the body.
// Current-user messages use <@id>/<@all>; bot and webhook messages use
// @id/@mobile/@all.
func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string {
text = normalizeAtPlaceholders(text, ids, wrapAngle)
allPlaceholder := "@all"
if wrapAngle {
text = normalizeAtPlaceholders(text, []string{"all"}, true)
allPlaceholder = "<@all>"
} else {
text = strings.ReplaceAll(text, "<@all>", "@all")
}
if atAll && !containsMessageMention(text, allPlaceholder) {
text = allPlaceholder + " " + text
}
return text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
}
for searchFrom := 0; ; {
offset := strings.Index(text[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(text) {
return true
}
next, _ := utf8.DecodeRuneInString(text[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) &&
next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -1582,8 +1624,23 @@ func newChatCommand() *cobra.Command {
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
dentryId, spaceId, fileName, fileType, filePath, fileSize)
}
case "location":
latitude, _ := cmd.Flags().GetString("latitude")
longitude, _ := cmd.Flags().GetString("longitude")
locationName, _ := cmd.Flags().GetString("location-name")
mapThumbnailUrl, _ := cmd.Flags().GetString("map-thumbnail-url")
if latitude == "" || longitude == "" || locationName == "" || mapThumbnailUrl == "" {
return fmt.Errorf("--latitude, --longitude, --location-name, --map-thumbnail-url are all required for msgType=location")
}
contentJSON = fmt.Sprintf(`{"locationName":"%s","longitude":"%s","latitude":"%s","mapThumbnailUrl":"%s"}`, locationName, longitude, latitude, mapThumbnailUrl)
case "profile":
contactID, _ := cmd.Flags().GetString("contact-id")
if contactID == "" {
return fmt.Errorf("--contact-id is required for msgType=profile")
}
contentJSON = fmt.Sprintf(`{"openDingTalkId":"%s"}`, contactID)
default:
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video)", msgType)
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video, location, profile)", msgType)
}
params := map[string]any{
@@ -2184,8 +2241,10 @@ func newChatCommand() *cobra.Command {
}
if cmd.Flags().Changed("only-robot") {
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot")
} else if cmd.Flags().Changed("only-robot-messages") {
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages")
}
if v, _ := cmd.Flags().GetString("conversation-type"); v != "" {
if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" {
toolArgs["searchConvType"] = v
}
@@ -2492,7 +2551,12 @@ func newChatCommand() *cobra.Command {
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("map-thumbnail-url", "", "位置消息地图缩略图 mediaId,形如 @mediaId(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("contact-id", "", "联系人名片 openDingTalkId(msgType=profile 时必填)")
chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
@@ -2686,7 +2750,11 @@ func newChatCommand() *cobra.Command {
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("group")
chatMessageSearchAdvancedCmd.Flags().String("message-type", "", "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)")
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot", false, "只搜索机器人消息(可选;显式传 false 时也会传给下层)")
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot-messages", false, "--only-robot 的别名")
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("only-robot-messages")
chatMessageSearchAdvancedCmd.Flags().String("conversation-type", "", "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)")
chatMessageSearchAdvancedCmd.Flags().String("search-conv-type", "", "--conversation-type 的别名")
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("search-conv-type")
chatMessageSearchAdvancedCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选)")
chatMessageSearchAdvancedCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选)")
chatMessageSearchAdvancedCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\")")
@@ -3186,6 +3254,40 @@ func newChatCommand() *cobra.Command {
chatMessageRemoveTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)")
chatMessageRemoveTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)")
chatMessageUpdateTextEmotionCmd := &cobra.Command{
Use: "update-text-emotion",
Short: "更新消息的文字表情回应",
Example: ` dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name "赞" --text "nice" --background-id im_bg_5`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
}
return callMCPToolOnServer("im", "update_text_emotion", map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
"openMsgId": mustGetFlag(cmd, "msg-id"),
"oldEmotionId": mustGetFlag(cmd, "old-emotion-id"),
"emotionId": mustGetFlag(cmd, "emotion-id"),
"emotionName": mustGetFlag(cmd, "emotion-name"),
"text": mustGetFlag(cmd, "text"),
"backgroundId": mustGetFlag(cmd, "background-id"),
})
},
}
chatMessageUpdateTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
chatMessageUpdateTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("old-emotion-id", "", "待更新的原表情 ID (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-id", "", "新表情 ID (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-name", "", "新表情名称 (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("text", "", "新文字内容 (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("background-id", "", "新背景 ID (必填)")
chatMessageUpdateTextEmotionCmd.MarkFlagsOneRequired("conversation-id", "group", "id", "chat")
for _, name := range []string{"msg-id", "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"} {
_ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired(name)
}
// ── 创建文字表情(获取 emotionId)──────────────────────
chatMessageCreateTextEmotionCmd := &cobra.Command{
@@ -3415,7 +3517,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageUpdateTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
chatBotCmd.AddCommand(chatBotSearchCmd)
chatCategoryCmd.AddCommand(chatCategoryListCmd, chatCategoryConvsCmd, chatCategoryCreateCmd, chatCategoryDeleteCmd, chatCategoryRenameCmd, chatCategoryAddConvCmd, chatCategoryRemoveConvCmd, chatCategoryListByConvCmd, chatCategoryBatchInfoCmd)
chatGroupCmd.AddCommand(chatGroupInfoByIdCmd)
@@ -3732,6 +3834,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatSetTopCmd.MarkFlagRequired("conversation-id")
chatSetTopCmd.Flags().Bool("off", false, "取消置顶(不传则设置置顶)")
// ── group get-mute-config: 查询群用户禁言配置 ───────────────
chatGroupGetMuteConfigCmd := &cobra.Command{
Use: "get-mute-config",
Short: "查询群用户禁言配置",
Long: `查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。
返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。`,
Example: ` dws chat group get-mute-config --group <openConversationId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
return callMCPToolOnServer("im", "get_group_mute_config", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
})
},
}
chatGroupGetMuteConfigCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
chatGroupCmd.AddCommand(chatGroupGetMuteConfigCmd)
// ── group-mute: 全员禁言 ───────────────────────────────
chatGroupMuteCmd := &cobra.Command{
@@ -5310,6 +5431,68 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatTextCmd.AddCommand(chatTextTranslateCmd)
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
// ── chat group user-settings ──
chatGroupUserSettingsCmd := &cobra.Command{
Use: "user-settings",
Short: "批量查询或更新当前用户的群会话设置",
RunE: groupRunE,
}
chatGroupUserSettingsQueryCmd := &cobra.Command{
Use: "query",
Short: "批量查询当前用户的群会话设置",
Example: ` dws chat group user-settings query --groups cid1,cid2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "groups"); err != nil {
return err
}
convIds := parseCSVValues(mustGetFlag(cmd, "groups"))
if len(convIds) == 0 {
return fmt.Errorf("--groups must not be empty")
}
if len(convIds) > 100 {
return fmt.Errorf("--groups batch size %d exceeds limit 100", len(convIds))
}
return callMCPToolOnServer("im", "batch_query_group_chat_settings", map[string]any{
"openConversationIds": convIds,
})
},
}
chatGroupUserSettingsQueryCmd.Flags().String("groups", "", "群会话 openConversationId 列表,逗号分隔,最多 100 个 (必填)")
chatGroupUserSettingsSetCmd := &cobra.Command{
Use: "set",
Short: "批量更新当前用户的群会话设置",
Example: ` dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "items"); err != nil {
return err
}
itemsJSON := mustGetFlag(cmd, "items")
var items []map[string]any
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
return fmt.Errorf("--items JSON parse error: %w", err)
}
if len(items) == 0 {
return fmt.Errorf("--items must not be empty")
}
if len(items) > 100 {
return fmt.Errorf("--items batch size %d exceeds limit 100", len(items))
}
// 每项必须携带非空 openConversationId,缺失时 fail-fast,避免下发无效批量更新。
for i, item := range items {
cid, _ := item["openConversationId"].(string)
if strings.TrimSpace(cid) == "" {
return fmt.Errorf("--items[%d] 缺少非空 openConversationId", i)
}
}
return callMCPToolOnServer("im", "batch_update_group_chat_settings", map[string]any{
"items": items,
})
},
}
chatGroupUserSettingsSetCmd.Flags().String("items", "", `JSON 数组,每项 {"openConversationId":"cid","top":bool,"mute":bool,"groupNick":"...","groupAlias":"..."} (必填)`)
chatGroupUserSettingsCmd.AddCommand(chatGroupUserSettingsQueryCmd, chatGroupUserSettingsSetCmd)
chatGroupCmd.AddCommand(chatGroupUserSettingsCmd)
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
chatBotCmd.AddCommand(chatBotFindCmd)
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
@@ -114,6 +114,18 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
if got := NormalizeMessageMentions("<@all> <@u1>", []string{"u1"}, true, false); got != "@all @u1" {
t.Fatalf("bot mention normalization = %q", got)
}
if got := NormalizeMessageMentions("@alliance hello", nil, true, false); got != "@all @alliance hello" {
t.Fatalf("bot @all token detection = %q", got)
}
if got := NormalizeMessageMentions("hello @all", nil, true, false); got != "hello @all" {
t.Fatalf("trailing bot @all detection = %q", got)
}
}
func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import "context"
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
// metadata to built-in semantic Shortcuts. It remains an alias so the native
// Cobra leaf and Shortcut use exactly the same upload implementation.
type ConversationLocalFileMeta = conversationLocalFileMeta
// BuildConversationLocalFileMeta validates a local file and computes the
// metadata required by DingTalk's conversation-file upload flow.
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
}
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
// commit flow and returns the commit response for message-content assembly.
func UploadConversationLocalFile(
ctx context.Context,
targetArgs map[string]any,
meta ConversationLocalFileMeta,
uuid string,
) (string, error) {
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
}
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
return parseConversationFileSendIDs(text)
}
// BuildConversationFileContent renders the exact file-message content accepted
// by send_personal_message.
func BuildConversationFileContent(
dentryID, spaceID int64,
meta ConversationLocalFileMeta,
) (string, error) {
return buildConversationFileContent(dentryID, spaceID, meta)
}
@@ -0,0 +1,209 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func executeChatPortsCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "conversation-id flag",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--msg-id", "msg-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
"--background-id", "im_bg_5",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-1",
"openMsgId": "msg-1",
"oldEmotionId": "old-1",
"emotionId": "new-1",
"emotionName": "like",
"text": "nice",
"backgroundId": "im_bg_5",
},
},
},
{
name: "group alias for conversation-id",
args: []string{
"message", "update-text-emotion",
"--group", "conv-2",
"--msg-id", "msg-2",
"--old-emotion-id", "old-2",
"--emotion-id", "new-2",
"--emotion-name", "heart",
"--text", "great",
"--background-id", "im_bg_1",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-2",
"openMsgId": "msg-2",
"oldEmotionId": "old-2",
"emotionId": "new-2",
"emotionName": "heart",
"text": "great",
"backgroundId": "im_bg_1",
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err != nil {
t.Fatalf("chat %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
fullArgs := []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--msg-id", "msg-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
"--background-id", "im_bg_5",
}
dropFlag := func(name string) []string {
out := make([]string, 0, len(fullArgs))
for i := 0; i < len(fullArgs); i++ {
if fullArgs[i] == name {
i++ // skip the flag value too
continue
}
out = append(out, fullArgs[i])
}
return out
}
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing conversation-id and aliases",
args: dropFlag("--conversation-id"),
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
},
{
name: "missing old-emotion-id",
args: dropFlag("--old-emotion-id"),
wantErr: `required flag(s) "old-emotion-id" not set`,
},
{
name: "missing msg-id and background-id",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
},
wantErr: `required flag(s) "background-id", "msg-id" not set`,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config", "--group", "conv-1")
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"muteBlackList":[],"muteWhiteList":[]}`},
}}
err := executeChatPortsCoverageCommand(t, caller,
"group", "get-mute-config", "--group", "conv-9")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
if caller.calls[0]["openConversationId"] != "conv-9" {
t.Fatalf("raw args = %#v", caller.calls[0])
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config")
if err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("err = %v, want message containing --group", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
}
@@ -0,0 +1,86 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionMapsReplacementTemplate(t *testing.T) {
for _, locator := range []string{"conversation-id", "group", "id", "chat"} {
t.Run(locator, func(t *testing.T) {
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
"message", "update-text-emotion",
"--"+locator, "cid",
"--msg-id", "mid",
"--old-emotion-id", "old-emotion",
"--emotion-id", "new-emotion",
"--emotion-name", "赞",
"--text", "nice",
"--background-id", "im_bg_5",
)
if err != nil {
t.Fatalf("update-text-emotion returned error: %v", err)
}
requireWukongWeeklySyncCall(t, caller, wukongWeeklySyncCall{
server: "im",
tool: "update_text_emotion",
args: map[string]any{
"openConversationId": "cid",
"openMsgId": "mid",
"oldEmotionId": "old-emotion",
"emotionId": "new-emotion",
"emotionName": "赞",
"text": "nice",
"backgroundId": "im_bg_5",
},
})
})
}
}
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionRequiresEveryBusinessParameter(t *testing.T) {
required := []struct {
name string
args []string
}{
{name: "conversation-id", args: []string{"--group", "cid"}},
{name: "msg-id", args: []string{"--msg-id", "mid"}},
{name: "old-emotion-id", args: []string{"--old-emotion-id", "old-emotion"}},
{name: "emotion-id", args: []string{"--emotion-id", "new-emotion"}},
{name: "emotion-name", args: []string{"--emotion-name", "赞"}},
{name: "text", args: []string{"--text", "nice"}},
{name: "background-id", args: []string{"--background-id", "im_bg_5"}},
}
for omitted := range required {
t.Run("missing-"+required[omitted].name, func(t *testing.T) {
args := []string{"message", "update-text-emotion"}
for i, parameter := range required {
if i != omitted {
args = append(args, parameter.args...)
}
}
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, args...)
if err == nil || !strings.Contains(err.Error(), required[omitted].name) {
t.Fatalf("missing %s error = %v", required[omitted].name, err)
}
requireWukongWeeklySyncNoCalls(t, caller)
})
}
}
+162
View File
@@ -0,0 +1,162 @@
package helpers
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageChatGroupUserSettingsQueryValidation(t *testing.T) {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "query", "--groups", ","); err == nil {
t.Fatal("empty groups returned nil")
}
tooMany := make([]string, 101)
for i := range tooMany {
tooMany[i] = "cid"
}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "query", "--groups", strings.Join(tooMany, ",")); err == nil {
t.Fatal("101 groups returned nil")
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetValidation(t *testing.T) {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", "[]"); err == nil {
t.Fatal("empty items returned nil")
}
var sb strings.Builder
sb.WriteString("[")
for i := 0; i < 101; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"openConversationId":"cid"}`)
}
sb.WriteString("]")
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", sb.String()); err == nil {
t.Fatal("101 items returned nil")
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetItemValidation(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"top":true}]`)
if err == nil || !strings.Contains(err.Error(), "openConversationId") || !strings.Contains(err.Error(), "--items[0]") {
t.Fatalf("err = %v, want missing openConversationId error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want no MCP call on invalid item", caller.calls)
}
// 非首项缺失时错误需带正确下标;空白字符串同样视为缺失。
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1"},{"openConversationId":" "}]`)
if err == nil || !strings.Contains(err.Error(), "--items[1]") {
t.Fatalf("err = %v, want --items[1] error", err)
}
// 合法条目仍然成功下发。
caller = &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true,"mute":false}]`); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "batch_update_group_chat_settings" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetHappyPath(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true}]`)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "im" || call.toolName != "batch_update_group_chat_settings" {
t.Fatalf("call = %#v", call)
}
}
func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location", "--latitude", "39.9")
if err == nil || !strings.Contains(err.Error(), "are all required for msgType=location") {
t.Fatalf("err = %v, want missing location params", err)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location",
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["msgType"] != "location" {
t.Fatalf("msgType = %#v", call.args["msgType"])
}
content, _ := call.args["content"].(string)
if !strings.Contains(content, `"latitude":"39.9"`) || !strings.Contains(content, `"mapThumbnailUrl":"@media1"`) {
t.Fatalf("content = %s", content)
}
}
func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile")
if err == nil || !strings.Contains(err.Error(), "--contact-id is required") {
t.Fatalf("err = %v, want contact-id required", err)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
content, _ := caller.calls[0].args["content"].(string)
if !strings.Contains(content, `"openDingTalkId":"od123"`) {
t.Fatalf("content = %s", content)
}
}
func TestCrossPlatformCoverageChatSearchAdvancedWukongAliases(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "search-advanced", "--query", "通知",
"--only-robot-messages", "--search-conv-type", "group_chat")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
if args["onlyRobotMessages"] != true {
t.Fatalf("onlyRobotMessages = %#v", args["onlyRobotMessages"])
}
if args["searchConvType"] != "group_chat" {
t.Fatalf("searchConvType = %#v", args["searchConvType"])
}
}
@@ -287,6 +287,14 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
path: "sheet range move-to",
args: []string{"range", "move-to", "--node", "node-1", "--sheet-id", "sheet-1", "--source-range", "A1:B3", "--target-range", "D1"},
},
{
path: "sheet comment delete",
args: []string{"comment", "delete", "--node", "node-1", "--comment-key", "ck-1"},
},
{
path: "sheet version revert",
args: []string{"version", "revert", "--node", "node-1", "--version", "2"},
},
}
// A newly protected command must add a runnable case here. This keeps the
+1 -1
View File
@@ -2901,7 +2901,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)
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
return root
}
+321
View File
@@ -0,0 +1,321 @@
package helpers
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// doc_style.go — `dws doc style`(文档封面/背景样式配置)。
// 写入收口到 MCP 工具 update_document_style;读取用 get_document_style。
const docStyleToolName = "update_document_style"
const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
命令结构:
dws doc style cover set 设置文档封面
dws doc style cover clear 移除文档封面
dws doc style background set 设置文档背景纯色
dws doc style background clear 清除文档背景
dws doc style get 读取文档封面/背景 (只读)
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
coverCmd := &cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
Long: `为钉钉文档设置顶部封面图,可指定竖直显示位置。图片来源二选一:--image 外链或 --file 本地文件。`,
Example: ` dws doc style cover set --node DOC_ID --image https://img.example.com/cover.png
dws doc style cover set --node DOC_ID --file ./cover.png --position 0.3`,
RunE: runDocStyleCoverSet,
}
coverClearCmd := &cobra.Command{
Use: "clear",
Short: "移除文档封面",
Example: ` dws doc style cover clear --node DOC_ID`,
RunE: runDocStyleCoverClear,
}
backgroundCmd := &cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
Long: `为钉钉文档设置背景纯色(--color)。背景仅支持纯色,不支持背景图片上传(对齐前端能力)。`,
Example: ` dws doc style background set --node DOC_ID --color "#E8F2FE"`,
RunE: runDocStyleBackgroundSet,
}
backgroundClearCmd := &cobra.Command{
Use: "clear",
Short: "清除文档背景",
Example: ` dws doc style background clear --node DOC_ID`,
RunE: runDocStyleBackgroundClear,
}
getCmd := &cobra.Command{
Use: "get",
Short: "读取文档封面/背景 (只读)",
Long: `读取钉钉文档当前的封面与背景配置(只读,单接口收口 get_document_style)。`,
Example: ` dws doc style get --node DOC_ID`,
RunE: runDocStyleGet,
}
// cover set flags
coverSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
coverSetCmd.Flags().String("image", "", "封面图片 URL (外链会自动转存为内部地址)")
coverSetCmd.Flags().String("file", "", "本地图片文件路径 (与 --image 互斥)")
coverSetCmd.Flags().Float64("position", 0.5, "封面竖直位置 [0,1],默认 0.5")
coverSetCmd.MarkFlagsMutuallyExclusive("image", "file")
// cover clear flags
coverClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// background set flags(仅纯色,对齐前端;不支持背景图片上传)
backgroundSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
backgroundSetCmd.Flags().String("color", "", "背景纯色,如 #E8F2FE (必填)")
// background clear flags
backgroundClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// get flags
getCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// --node 隐藏别名 (--url/--id/--node-id/--doc-id/--file-id)
for _, c := range []*cobra.Command{coverSetCmd, coverClearCmd, backgroundSetCmd, backgroundClearCmd, getCmd} {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
c.Flags().String("node-id", "", "--node 的别名")
c.Flags().String("doc-id", "", "--node 的别名")
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
coverCmd.AddCommand(coverSetCmd, coverClearCmd)
backgroundCmd.AddCommand(backgroundSetCmd, backgroundClearCmd)
styleCmd.AddCommand(coverCmd, backgroundCmd, getCmd)
// 递归注册跨产品别名,让 cover set 的 --file 获得统一别名 --file-path。
registerCrossProductAliasesRecursive(styleCmd)
return styleCmd
}
// registerCrossProductAliasesRecursive 递归为 cmd 及其所有子命令注册跨产品别名。
// 可正确处理多层分组(如 style 的 cover/background 组下还有 set/clear 叶子)。
func registerCrossProductAliasesRecursive(cmd *cobra.Command) {
RegisterCrossProductAliases(cmd)
for _, sub := range cmd.Commands() {
registerCrossProductAliasesRecursive(sub)
}
}
// docStyleNodeID 解析目标文档标识,支持 --node 及隐藏别名。
func docStyleNodeID(cmd *cobra.Command) (string, error) {
return mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
}
func runDocStyleCoverSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 封面图片来源二选一:--image 外链(服务端转存公开)或 --file 本地上传(拿 resourceId 转存公开)。
image, _ := cmd.Flags().GetString("image")
filePath := flagOrFallback(cmd, "file", "file-path")
// --image 与 --file/--file-path 互斥。cobra 的 MarkFlagsMutuallyExclusive 只认主名
// image/file,拦不住别名 --file-path,故在此显式兜底。
if image != "" && filePath != "" {
return fmt.Errorf("--image and --file/--file-path are mutually exclusive; specify only one")
}
if image == "" && filePath == "" {
return fmt.Errorf("flag --image or --file is required for `cover set`")
}
cover := map[string]any{"action": "set"}
// position 客户端校验:帮助文档承诺 [0,1],越界直接报错,避免触发上传/请求。
if cmd.Flags().Changed("position") {
pos, _ := cmd.Flags().GetFloat64("position")
if pos < 0 || pos > 1 {
return fmt.Errorf("--position must be within [0,1], got %v", pos)
}
cover["position"] = pos
}
if image != "" {
cover["imageUrl"] = image
} else {
// --file:先做本地校验(存在 + 必须为图片);dry-run 时在上传前短路,
// 绝不读取/上传本地文件。
mimeType, fileSize, err := validateCoverImageFile(filePath)
if err != nil {
return err
}
if deps.Caller.DryRun() {
cover["resourceId"] = fmt.Sprintf("(pending upload from --file %s)", filePath)
} else {
// 使用命令上下文,让 Ctrl-C 能够取消上传凭证获取与 OSS 上传两次网络请求。
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
resourceID, err := uploadDocStyleImage(ctx, nodeID, filePath, filepath.Base(filePath), mimeType, fileSize)
if err != nil {
return err
}
cover["resourceId"] = resourceID
}
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": cover,
})
}
func runDocStyleCoverClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": map[string]any{"action": "clear"},
})
}
func runDocStyleBackgroundSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 背景仅支持纯色(对齐前端,不支持背景图片上传)。
color, _ := cmd.Flags().GetString("color")
if color == "" {
return fmt.Errorf("flag --color is required for `background set`")
}
// 帮助文档承诺 #RRGGBB 十六进制纯色,非法值直接报错,不下发请求。
if !isHexColor(color) {
return fmt.Errorf("--color must be a hex color like #E8F2FE, got %q", color)
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{
"action": "set",
"backgroundColor": color,
},
})
}
func runDocStyleBackgroundClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{"action": "clear"},
})
}
// runDocStyleGet 读取文档当前封面/背景(只读)。
func runDocStyleGet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleGetToolName, map[string]any{
"nodeId": nodeID,
})
}
// docStyleCoverMaxBytes 是 cover set --file 本地图片的大小上限(20 MiB)。
// 悟空参考实现未设上限,此处补充客户端 fail-fast,避免超大文件被先行上传。
const docStyleCoverMaxBytes = 20 << 20
// validateCoverImageFile 对 cover set --file 做本地校验(不上传/不读取文件内容):
// 文件须存在、非目录、不超过大小上限,且按扩展名推断的 MIME 必须为 image/*。
// 返回 mimeType 与文件大小,供 dry-run 预览与真实上传前 fail-fast,避免非法文件被先行上传。
func validateCoverImageFile(filePath string) (mimeType string, fileSize int64, err error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return "", 0, fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return "", 0, fmt.Errorf("%s is a directory, not a file", filePath)
}
if fileInfo.Size() > docStyleCoverMaxBytes {
return "", 0, fmt.Errorf("封面图片文件过大:上限 %d 字节 (20 MiB),实际 %d 字节 (%s)", int64(docStyleCoverMaxBytes), fileInfo.Size(), filepath.Base(filePath))
}
mimeType = inferMimeType(filepath.Base(filePath))
if !strings.HasPrefix(mimeType, "image/") {
return "", 0, fmt.Errorf("cover --file must be an image (png/jpg/jpeg/gif/svg/webp), got %s", filepath.Base(filePath))
}
return mimeType, fileInfo.Size(), nil
}
// isHexColor 校验 #RRGGBB 形式的十六进制颜色(大小写均可)。
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
for _, c := range s[1:] {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// uploadDocStyleImage 复用附件上传子流程(get_doc_attachment_upload_info + httpPutFile),
// 上传本地文件为鉴权资源,返回其 resourceId(供封面在服务端转存为公开读地址)。
// 入参 mimeType/fileSize 由调用方经 validateCoverImageFile 预校验得到;进度写 stderr,
// 保证 --format json 时 stdout 只含合法 JSON。本函数一定发起网络请求,调用方须自行做 dry-run 短路。
func uploadDocStyleImage(ctx context.Context, nodeID, filePath, fileName, mimeType string, fileSize int64) (string, error) {
deps.Out.PrintProgress(fmt.Sprintf("[1/2] 获取图片上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileSize),
"mimeType": mimeType,
})
if err != nil {
return "", err
}
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
if err != nil {
return "", err
}
deps.Out.PrintProgress("[2/2] 上传图片到 OSS...")
ossHeaders := map[string]string{"Content-Type": mimeType}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return "", err
}
return resourceID, nil
}
+230
View File
@@ -0,0 +1,230 @@
package helpers
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempImage(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte("fake-image-bytes"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestCrossPlatformCoverageValidateCoverImageFile(t *testing.T) {
if _, _, err := validateCoverImageFile(t.TempDir()); err == nil {
t.Fatal("directory returned nil")
}
path := writeTempImage(t, "cover.png")
mimeType, size, err := validateCoverImageFile(path)
if err != nil {
t.Fatal(err)
}
if mimeType != "image/png" || size != int64(len("fake-image-bytes")) {
t.Fatalf("mimeType=%q size=%d", mimeType, size)
}
}
func TestCrossPlatformCoverageIsHexColorEdge(t *testing.T) {
for _, valid := range []string{"#E8F2FE", "#e8f2fe", "#000000", "#aB1234"} {
if !isHexColor(valid) {
t.Fatalf("%s should be valid", valid)
}
}
for _, invalid := range []string{"#GG0000", "E8F2FE", "#E8F2F", "#E8F2FEE", ""} {
if isHexColor(invalid) {
t.Fatalf("%s should be invalid", invalid)
}
}
}
func executeDocStyleCommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
root := newDocStyleCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDocStyleCoverSetMutualExclusion(t *testing.T) {
err := executeDocStyleCommand(t, &scriptedToolCaller{},
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--file-path", "a.png")
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err = %v, want mutual exclusion", err)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetPositionValidation(t *testing.T) {
err := executeDocStyleCommand(t, &scriptedToolCaller{},
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "1.5")
if err == nil || !strings.Contains(err.Error(), "--position must be within [0,1]") {
t.Fatalf("err = %v, want position range error", err)
}
caller := &scriptedToolCaller{}
if err := executeDocStyleCommand(t, caller,
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "0.5"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetFileDryRun(t *testing.T) {
path := writeTempImage(t, "cover.png")
caller := &scriptedToolCaller{dry: true}
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
t.Fatal(err)
}
}
func stubHTTPPutFile(t *testing.T, err error) {
t.Helper()
old := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return err }
t.Cleanup(func() { httpPutFile = old })
}
func TestCrossPlatformCoverageDocStyleCoverSetFileUpload(t *testing.T) {
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetFileUploadFailure(t *testing.T) {
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, errors.New("oss down"))
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
}}
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
if err == nil || !strings.Contains(err.Error(), "oss down") {
t.Fatalf("err = %v, want oss failure", err)
}
}
func TestCrossPlatformCoverageUploadDocStyleImageFailures(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("cred failed")}}}
installScriptedCaller(t, caller)
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
t.Fatal("cred failure returned nil")
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `not json`}}}
installScriptedCaller(t, caller)
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
t.Fatal("invalid upload info returned nil")
}
}
func TestCrossPlatformCoverageValidateCoverImageFileSizeLimit(t *testing.T) {
// 上限以内的正常图片通过校验。
path := writeTempImage(t, "cover.png")
if _, _, err := validateCoverImageFile(path); err != nil {
t.Fatal(err)
}
// os.Truncate 生成超过上限 1 字节的稀疏文件(瞬时创建,不占实际磁盘)。
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
t.Fatal(err)
}
_, _, err := validateCoverImageFile(path)
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") || !strings.Contains(err.Error(), "20 MiB") {
t.Fatalf("err = %v, want size limit error", err)
}
// 恰好等于上限时仍应通过。
if err := os.Truncate(path, docStyleCoverMaxBytes); err != nil {
t.Fatal(err)
}
if _, _, err := validateCoverImageFile(path); err != nil {
t.Fatalf("size == limit should pass, got %v", err)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetSizeLimitViaCommand(t *testing.T) {
path := writeTempImage(t, "cover.png")
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{}
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") {
t.Fatalf("err = %v, want size limit error", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0 (fail before any network request)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetNilContextFallback(t *testing.T) {
// 直接调用 RunE(不经 Execute),cmd.Context() 为 nil,覆盖 context.Background() 兜底分支。
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
installScriptedCaller(t, caller)
root := newDocStyleCommand()
coverSet, _, err := root.Find([]string{"cover", "set"})
if err != nil {
t.Fatal(err)
}
if coverSet.Context() != nil {
t.Fatal("expected nil context on unexecuted command")
}
if err := coverSet.Flags().Set("node", "n1"); err != nil {
t.Fatal(err)
}
if err := coverSet.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
if err := runDocStyleCoverSet(coverSet, nil); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetUsesCommandContext(t *testing.T) {
// 经 ExecuteContext 执行时,上传流程应使用命令上下文;scriptedToolCaller 忽略 ctx,
// 故此处做行为性断言:正常上下文下上传路径成功走通(覆盖 ctx := cmd.Context() 分支)。
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
installScriptedCaller(t, caller)
root := newDocStyleCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"cover", "set", "--node", "n1", "--file", path})
if err := root.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
+424 -1
View File
@@ -302,9 +302,68 @@ func newDriveCommand() *cobra.Command {
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
if cmd.Flags().Changed("versions") {
if cmd.Flags().Changed("depth") && depth > 1 {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --depth 不能同时使用",
}
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --pattern 不能同时使用",
}
}
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPToolOnServer("drive", "list_file_versions", toolArgs)
}
if cmd.Flags().Changed("depth") {
if err := validateDriveListDepth(cmd, depth); err != nil {
return err
}
}
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
if rootFolder != "" {
if err := validateDocFolderID(rootFolder); err != nil {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--pattern 仅适用于钉盘文件列表,不能与 --workspace 同时使用",
}
}
toolArgs := map[string]any{"workspaceId": workspaceID}
if folder := docFolderFlag(cmd, "node", "file-id"); folder != "" {
if err := validateDocFolderID(folder); err != nil {
@@ -321,6 +380,30 @@ func newDriveCommand() *cobra.Command {
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
}
if depth > 1 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
baseArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("order"); v != "" {
baseArgs["order"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
// 默认路由:钉盘文件列表
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
@@ -451,6 +534,73 @@ func newDriveCommand() *cobra.Command {
},
}
driveDownloadVersionCmd := &cobra.Command{
Use: "download-version",
Short: "下载文件历史版本到本地",
Long: `下载钉盘文件的指定历史版本到本地(两步下载流程)。
仅适用于普通文件(如 pdf、docx、xlsx、png 等):
钉钉在线文档(adoc)请使用 dws doc version 系列命令
钉钉在线表格(axls)请使用 dws sheet version 系列命令
流程:
1. 获取历史版本下载 URL 和签名请求头 (download_file_version)
2. HTTP GET 下载文件二进制内容到本地
版本号从 dws drive list --node <dentryUuid> --versions 获取。`,
Example: ` dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf
dws drive download-version --node <dentryUuid> --version 3 --output ~/downloads/`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
versionNum, _ := cmd.Flags().GetInt("version")
if versionNum <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d(版本号从 drive list --versions 获取)", versionNum)
}
outputPath, _ := cmd.Flags().GetString("output")
if outputPath == "" {
return fmt.Errorf("flag --output is required")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
deps.Out.PrintKeyValue("输出", outputPath)
return nil
}
ctx := context.Background()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return err
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
filename := extractFileNameFromResponse(text)
if filename == "" {
filename = inferFilename(resourceURL)
}
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
return err
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
}
driveMkdirCmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
@@ -547,6 +697,11 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选,仅钉盘)")
driveListCmd.Flags().String("order", "", "排序方向: asc|desc,默认 desc (可选,仅钉盘)")
driveListCmd.Flags().Bool("thumbnail", false, "是否返回缩略图信息 (可选,仅钉盘)")
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -555,6 +710,14 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
driveMkdirCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)")
@@ -1124,7 +1287,151 @@ func newDriveCommand() *cobra.Command {
_ = c.Flags().MarkHidden("file-id")
}
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd)
drivePermTransferOwnerCmd := &cobra.Command{
Use: "transfer-owner",
Short: "[危险] 转交所有者",
Long: `转交文档或知识库的所有者给指定用户。此操作不可逆,执行前需要确认。
--node 和 --workspace 二选一。转交后原所有者保留角色由 --reserve-role 指定。
使用 --yes 跳过确认时,--reserve-role 和 --recursive 必须显式指定。`,
Example: ` dws drive permission transfer-owner --node DOC_ID --new-owner uid123 --reserve-role EDITOR --recursive=false --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, _ := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if nodeID == "" && workspaceID == "" {
return fmt.Errorf("--node or --workspace is required")
}
// 不可逆高危操作:帮助文档承诺二选一,两者同传直接失败,绝不静默择一。
if nodeID != "" && workspaceID != "" {
return fmt.Errorf("--node and --workspace are mutually exclusive; specify exactly one")
}
if err := validateRequiredFlags(cmd, "new-owner"); err != nil {
return err
}
newOwnerID := mustGetFlag(cmd, "new-owner")
yesMode, _ := cmd.Flags().GetBool("yes")
if yesMode {
if !cmd.Flags().Changed("reserve-role") {
return fmt.Errorf("--reserve-role is required when using --yes")
}
if !cmd.Flags().Changed("recursive") {
return fmt.Errorf("--recursive is required when using --yes")
}
}
if commandDryRun(cmd) {
if deps.Caller.Format() == "json" {
payload := map[string]any{
"dry_run": true,
"executed": false,
"operation": "转交所有者",
"newOwnerId": newOwnerID,
}
if nodeID != "" {
payload["nodeId"] = nodeID
} else {
payload["workspaceId"] = workspaceID
}
return deps.Out.PrintJSON(payload)
}
deps.Out.PrintKeyValue("操作", "转交所有者")
deps.Out.PrintKeyValue("新所有者", newOwnerID)
return nil
}
reserveRole := mustGetFlag(cmd, "reserve-role")
recursive, _ := cmd.Flags().GetBool("recursive")
target := nodeID
if target == "" {
target = workspaceID
}
if !yesMode && !confirmDangerousAction(cmd, "转交所有者", target) {
return nil
}
toolArgs := map[string]any{"newOwnerId": newOwnerID}
if nodeID != "" {
toolArgs["nodeId"] = nodeID
} else {
toolArgs["workspaceId"] = workspaceID
}
if reserveRole != "" {
toolArgs["reserveOldOwnerRole"] = reserveRole
}
if cmd.Flags().Changed("recursive") {
toolArgs["recursiveChange"] = recursive
}
return callMCPToolOnServer("doc", "transfer_owner", toolArgs)
},
}
drivePermTransferOwnerCmd.Flags().String("node", "", "目标节点 ID 或 URL(与 --workspace 二选一)")
drivePermTransferOwnerCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(与 --node 二选一)")
drivePermTransferOwnerCmd.Flags().String("new-owner", "", "新所有者的用户 userId (必填)")
drivePermTransferOwnerCmd.Flags().String("reserve-role", "", "转交后原所有者保留角色: MANAGER / EDITOR / DOWNLOADER / READER / NONE")
drivePermTransferOwnerCmd.Flags().Bool("recursive", false, "是否递归变更所有子节点的所有者")
drivePermApplyInfoCmd := &cobra.Command{
Use: "apply-info",
Short: "查询节点可申请的角色与审批人",
Example: ` dws drive permission apply-info --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "query_permission_apply_info", map[string]any{"nodeId": nodeID})
},
}
drivePermApplyInfoCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd := &cobra.Command{
Use: "apply",
Short: "发起权限申请",
Long: `向指定节点的审批人发起权限申请。建议先用 apply-info 获取可申请角色与审批人。
注意: 本命令会真实通知审批人,Agent 必须先获得用户明确同意后再执行。`,
Example: ` dws drive permission apply --node DOC_ID --role READER --users uid1
dws drive permission apply --node DOC_ID --role EDITOR --users uid1,uid2 --reason "需要编辑该文档"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"receivers": userIds,
}
if v := mustGetFlag(cmd, "notify-mode"); v != "" {
toolArgs["notifyMode"] = v
}
if v := mustGetFlag(cmd, "reason"); v != "" {
toolArgs["reason"] = v
}
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "发起权限申请", fmt.Sprintf("节点 %s(将真实通知审批人 %s)", nodeID, strings.Join(userIds, ","))) {
return nil
}
return callMCPToolOnServer("drive", "apply_permission", toolArgs)
},
}
drivePermApplyCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd.Flags().String("role", "", "申请的角色: EDITOR / DOWNLOADER / READER (必填)")
drivePermApplyCmd.Flags().String("users", "", "审批人 userId 列表,逗号分隔 (必填)")
drivePermApplyCmd.Flags().String("user", "", "")
_ = drivePermApplyCmd.Flags().MarkHidden("user")
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
@@ -1397,11 +1704,124 @@ func newDriveCommand() *cobra.Command {
driveRecentCmd.Flags().String("page-token", "", "")
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := &cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
}
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
Example: ` dws drive star add --node <nodeId_or_URL>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "mark_star", map[string]any{"nodeId": nodeID})
},
}
driveStarAddCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "取消收藏文档",
Example: ` dws drive star remove --node <nodeId_or_URL>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "unmark_star", map[string]any{"nodeId": nodeID})
},
}
driveStarRemoveCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarListCmd := &cobra.Command{
Use: "list",
Short: "获取收藏列表",
Example: ` dws drive star list
dws drive star list --content-types doc,sheet --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["limit"] = v
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
toolArgs["cursor"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
toolArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("sort"); v != "" {
toolArgs["sortType"] = v
}
if v, _ := cmd.Flags().GetStringSlice("resource-types"); len(v) > 0 {
toolArgs["supportResourceTypes"] = v
}
if v, _ := cmd.Flags().GetStringSlice("content-types"); len(v) > 0 {
toolArgs["contentTypes"] = v
}
return callMCPToolOnServer("drive", "get_star_list", toolArgs)
},
}
driveStarListCmd.Flags().Int("limit", 0, "每页条数 (默认 20,最大 20)")
driveStarListCmd.Flags().String("cursor", "", "分页游标")
driveStarListCmd.Flags().String("order-by", "", "排序字段: createTime")
driveStarListCmd.Flags().String("sort", "", "排序方向: asc|desc")
driveStarListCmd.Flags().StringSlice("resource-types", nil, "资源大类: DENTRY, TEAM, WORKSPACE")
driveStarListCmd.Flags().StringSlice("content-types", nil, "内容类型: doc,sheet,ppt,whiteboard,mind,notable,pdf,other,folder")
driveStarCmd.AddCommand(driveStarAddCmd, driveStarRemoveCmd, driveStarListCmd)
// ── drive cover (获取节点封面地址) ──
driveCoverCmd := &cobra.Command{
Use: "cover",
Short: "获取节点封面地址",
Example: " dws drive cover --node <dentryUuid>",
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_cover", map[string]any{"nodeId": nodeID})
},
}
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚文件到指定历史版本",
Long: `将指定文件回滚到某个历史版本。仅支持普通文件(Word、Excel、PDF、图片等)。
在线文档请用 dws doc version revert,在线表格请用 dws sheet version revert。`,
Example: ` dws drive revert --node <dentryUuid> --version 3 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
versionNum, err := cmd.Flags().GetInt("version")
if err != nil || versionNum <= 0 {
return fmt.Errorf("flag --version is required and must be a positive integer")
}
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "回滚文件版本", fmt.Sprintf("节点 %s 回滚到版本 %d", nodeID, versionNum)) {
return nil
}
return callMCPToolOnServer("drive", "revert_file_version", map[string]any{
"nodeId": nodeID,
"version": versionNum,
})
},
}
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
driveCmd.AddCommand(
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
driveUploadInfoCmd,
driveCommitCmd,
@@ -1418,6 +1838,9 @@ func newDriveCommand() *cobra.Command {
drivePermissionCmd,
drivePublishCmd,
recycleCmd,
driveStarCmd,
driveCoverCmd,
driveRevertCmd,
// deprecated 兼容命令(Phase 2)— 隐藏,保留向后兼容
driveFolderCmd,
)
+562
View File
@@ -0,0 +1,562 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// drive list --depth 递归编排(BFS + 限流补偿)
//
// 服务端无递归 API,CLI 侧 BFS 过渡方案;服务端递归 API 上线后本块整体退役。
// 钉盘(list_files)与知识库(list_nodes)共用一份路由无关骨架,差异收敛在 driveDepthRoute。
// ──────────────────────────────────────────────────────────
const (
// 50 来源于服务端 MAX_PAGE_SIZE 硬校验(超限抛错不 clamp),服务端放宽后仅需修改此常量。
driveDepthPageSize = 50
// 来源与 driveDepthPageSize 不同(list_nodes pageSize 上限),数值碰巧相同但不合并。
docDepthPageSize = 50
// API 调用数随目录宽度指数增长,超过报错不 clamp。
driveDepthMax = 5
driveDepthMaxItems = 2000
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
)
type driveDepthFolder struct {
id string // 钉盘 dentryUuid / 知识库 nodeId
name string
depth int
relPath string
retried bool // 已因 rate_limited 重新入队过一次,不二次补偿
// 限流重入队时保留失败页游标:已成功页的条目已进 collected,
// 从头重扫会产生重复条目并提前耗尽全局 2000 上限。
pageToken string
}
type driveDepthError struct {
Depth int `json:"depth"`
FolderID string `json:"folderId"`
FolderName string `json:"folderName"`
Reason string `json:"reason"`
Message string `json:"message"`
}
// 取消不是错误:不记 errors[]、不复用 1~6 退出码,固定 130(128+SIGINT)。
type driveDepthCancelledError struct{}
func (e *driveDepthCancelledError) Error() string { return driveDepthCancelledMsg }
func (e *driveDepthCancelledError) RawStderr() string { return driveDepthCancelledMsg }
func (e *driveDepthCancelledError) ExitCode() int { return 130 }
// depth==1 走原有单层路径,仅做范围校验,不触发互斥检查。
func validateDriveListDepth(cmd *cobra.Command, depth int) error {
if depth < 1 {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--depth 必须为 1~%d 的整数,当前: %d", driveDepthMax, depth),
}
}
if depth > driveDepthMax {
// 不 clamp:静默 clamp 会让用户误以为拿到了完整树。
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("DEPTH_EXCEED_MAX: --depth 最大为 %d,当前: %d(API 调用数随目录宽度指数增长,不做静默 clamp)", driveDepthMax, depth),
}
}
if depth == 1 {
return nil
}
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
}
return nil
}
func driveDepthExclusiveError(flag, reason string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--depth 不能与 --%s 同时使用:%s", flag, reason),
}
}
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
type driveDepthRoute struct {
serverID string // ""=钉盘自动路由 / "doc"=知识库
toolName string
pageSize int
// 钉盘契约无 hasMore 字段,只信空 nextToken;知识库契约含 hasMore,显式 false 即停。
// 钉盘的 hasMore 仅用于「hasMore=true 但 token 为空」异常检测。
hasMoreAuthoritative bool
buildArgs func(base map[string]any, folderID, pageToken string) map[string]any
parsePage func(text string) (items []map[string]any, nextToken string, hasMore bool)
isFolder func(item map[string]any) bool
itemID func(item map[string]any) string
}
func (r driveDepthRoute) fetchPage(ctx context.Context, args map[string]any) (string, error) {
if r.serverID == "" {
return callMCPToolReturnText(ctx, r.toolName, args)
}
return callMCPToolReturnTextOnServer(ctx, r.serverID, r.toolName, args)
}
func newDrivePanDepthRoute() driveDepthRoute {
return driveDepthRoute{
serverID: "",
toolName: "list_files",
pageSize: driveDepthPageSize,
hasMoreAuthoritative: false,
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
args := map[string]any{"maxResults": float64(driveDepthPageSize)}
for k, v := range base {
args[k] = v
}
if folderID != "" {
args["parentId"] = folderID
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
},
parsePage: parseDriveDepthPage,
isFolder: isDriveDepthFolder,
itemID: func(item map[string]any) string {
id, _ := item["fileId"].(string)
return id
},
}
}
// folderId 为空 = 知识库根。
func newDocDepthRoute() driveDepthRoute {
return driveDepthRoute{
serverID: "doc",
toolName: "list_nodes",
pageSize: docDepthPageSize,
// 字段缺失(契约违背)时按停处理——保守方向,宁可少翻页不空转。
hasMoreAuthoritative: true,
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
args := map[string]any{"pageSize": float64(docDepthPageSize)}
for k, v := range base {
args[k] = v
}
if folderID != "" {
args["folderId"] = folderID
}
if pageToken != "" {
args["pageToken"] = pageToken
}
return args
},
parsePage: parseDocDepthPage,
isFolder: isDocDepthFolder,
itemID: func(item map[string]any) string {
id, _ := item["nodeId"].(string)
return id
},
}
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
parent := cmd.Context()
if parent == nil {
parent = context.Background()
}
ctx, stop := signal.NotifyContext(parent, os.Interrupt)
defer stop()
// 兜底服务端「空页 + 非空游标」类 bug:该形态下条目不增长、全局 2000 永不触发。
// 不用「token 不推进即停」:兜不住每页 token 都不同的空页流。
maxPagesPerFolder := driveDepthMaxItems/route.pageSize + 1
traceID := fmt.Sprintf("drive-depth-%d", time.Now().UnixNano())
var (
collected []map[string]any
errs = make([]driveDepthError, 0)
truncated bool
visited = map[string]bool{}
)
queue := []driveDepthFolder{{id: rootFolderID, depth: 0}}
if rootFolderID != "" {
visited[rootFolderID] = true
}
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
folder := queue[0]
queue = queue[1:]
start := time.Now()
var folderErr error
pageToken := folder.pageToken
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
pages++
if pages > maxPagesPerFolder {
folderErr = &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("pagination anomaly: folder exceeded %d pages with non-empty nextToken, cursor loop suspected, directory may be truncated", maxPagesPerFolder),
}
break
}
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
if err != nil {
folderErr = err
break
}
items, next, hasMore := route.parsePage(text)
for _, item := range items {
name, _ := item["name"].(string)
rel := name
if folder.relPath != "" {
rel = folder.relPath + "/" + name
}
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
truncated = true
break
}
if route.isFolder(item) && folder.depth+1 < maxDepth {
if id := route.itemID(item); id != "" && !visited[id] {
// 重复条目静默丢弃
visited[id] = true
queue = append(queue, driveDepthFolder{id: id, name: name, depth: folder.depth + 1, relPath: rel})
}
}
}
if truncated {
break bfs
}
if hasMore && next == "" {
// 不静默截断——显式落 api_error,让消费方感知数据不完整。
folderErr = &CLIError{
Code: CodeMCPToolError,
Message: "pagination anomaly: hasMore=true but nextToken is empty, directory may be truncated",
}
break
}
if next == "" {
break
}
if route.hasMoreAuthoritative && !hasMore {
break
}
pageToken = next
}
slog.Info("drive list depth folder done",
"traceId", traceID, "depth", folder.depth, "folderId", folder.id,
"latency", time.Since(start).Milliseconds(), "failed", folderErr != nil)
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
return emitErr
}
return folderErr
}
if driveDepthErrorCode(folderErr) == driveDepthRateLimitedCode && !folder.retried {
// 限流命中在入口处、失败页本身无副作用;但此前成功页已进 collected,
// 必须从失败页游标续扫而非整目录重扫。不加 sleep/退避。
folder.retried = true
folder.pageToken = pageToken
queue = append(queue, folder)
continue
}
if folder.depth == 0 && len(collected) == 0 {
return folderErr
}
// 403 / transport 重试耗尽 / 限流重入队仍失败:记 errors[] 跳过
errs = append(errs, newDriveDepthError(folder, folderErr))
continue
}
if !quiet {
display := folder.name
if display == "" {
display = folder.id
}
if display == "" {
display = "<root>"
}
fmt.Fprintf(os.Stderr, "[drive-list] depth=%d folder=%s done\n", folder.depth, display)
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if matchDriveNamePattern(name, pattern) {
filtered = append(filtered, item)
}
}
items = filtered
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if items == nil {
items = []map[string]any{}
}
return deps.Out.PrintJSON(map[string]any{
"items": items,
"maxDepth": maxDepth,
"truncated": truncated,
"errors": errs,
})
}
// 不输出预估调用次数:零调用下平均子文件夹数不可知,硬编码上界会被误读为真实估算。
func printDriveDepthDryRun(route driveDepthRoute, baseArgs map[string]any, maxDepth int) error {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": route.toolName,
"baseArgs": baseArgs,
"maxDepth": maxDepth,
"pageSize": route.pageSize,
"totalLimit": driveDepthMaxItems,
"warning": "调用数随目录宽度指数增长",
})
}
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
deps.Out.PrintKeyValue("Tool", route.toolName)
argsJSON, _ := json.MarshalIndent(baseArgs, " ", " ")
deps.Out.PrintKeyValue("baseArgs", "\n "+string(argsJSON))
deps.Out.PrintKeyValue("maxDepth", fmt.Sprintf("%d", maxDepth))
deps.Out.PrintKeyValue("每页条数", fmt.Sprintf("%d", route.pageSize))
deps.Out.PrintKeyValue("全局上限", fmt.Sprintf("%d", driveDepthMaxItems))
deps.Out.PrintWarning("调用数随目录宽度指数增长")
return nil
}
// 钉盘契约无 hasMore 字段,hasMoreExplicit 仅供「hasMore=true 但 token 为空」异常检测。
func parseDriveDepthPage(text string) (items []map[string]any, nextToken string, hasMoreExplicit bool) {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil, "", false
}
target := body
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
target = inner
}
key := driveDepthListItemsKey(target)
if key == "" {
return nil, "", false
}
arr, _ := target[key].([]any)
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
items = append(items, m)
}
}
nextToken, _ = target["nextToken"].(string)
hasMoreExplicit, _ = target["hasMore"].(bool)
return items, nextToken, hasMoreExplicit
}
func driveDepthListItemsKey(m map[string]any) string {
for _, k := range []string{"items", "dentryList"} {
if arr, ok := m[k].([]any); ok && arr != nil {
return k
}
}
return ""
}
// 非法通配符降级为子串匹配,避免 filepath.Match 报错导致过滤静默失效。
func matchDriveNamePattern(name, pattern string) bool {
if pattern == "" {
return true
}
p := pattern
if !strings.ContainsAny(p, "*?[") {
p = "*" + p + "*"
}
ok, err := filepath.Match(p, name)
if err != nil {
return strings.Contains(name, pattern)
}
return ok
}
func isDriveDepthFolder(item map[string]any) bool {
for _, key := range []string{"type", "dentryType"} {
if v, ok := item[key].(string); ok && strings.EqualFold(v, "FOLDER") {
return true
}
}
return false
}
// list_nodes 游标字段叫 nextPageToken,与钉盘的 nextToken 不同名。
func parseDocDepthPage(text string) (items []map[string]any, nextToken string, hasMore bool) {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil, "", false
}
arr, _ := body["nodes"].([]any)
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
items = append(items, m)
}
}
nextToken, _ = body["nextPageToken"].(string)
hasMore, _ = body["hasMore"].(bool)
return items, nextToken, hasMore
}
func isDocDepthFolder(item map[string]any) bool {
v, _ := item["nodeType"].(string)
return strings.EqualFold(v, "folder")
}
func driveDepthItemID(item map[string]any) string {
for _, key := range []string{"fileId", "nodeId"} {
if s, ok := item[key].(string); ok && s != "" {
return s
}
}
return ""
}
func driveDepthUnrecoverable(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
switch cliErr.Code {
case CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable:
return true
}
}
return false
}
// CLIError.Message 保留了原始响应 JSON。
func driveDepthErrorCode(err error) string {
var cliErr *CLIError
if !errors.As(err, &cliErr) {
return ""
}
var body map[string]any
if json.Unmarshal([]byte(cliErr.Message), &body) != nil {
return ""
}
for _, key := range []string{"errorCode", "error_code", "code"} {
if s, ok := body[key].(string); ok && s != "" {
return s
}
}
return ""
}
// 匹配顺序是硬约束:invalidRequest.rateLimited 自带 "invalidRequest." 前缀,
// 若先做 invalidRequest.* 前缀分类,限流会被吞进参数错误类。必须先精确匹配限流,
// 再 forbidden.* 前缀,最后兜底 api_error。未知 reason 按 api_error 兼容。
func classifyDriveDepthReason(errorCode string) string {
switch {
case errorCode == driveDepthRateLimitedCode:
return "rate_limited"
case strings.HasPrefix(errorCode, "forbidden."):
return "permission_denied"
default:
return "api_error"
}
}
func newDriveDepthError(folder driveDepthFolder, err error) driveDepthError {
return driveDepthError{
Depth: folder.depth,
FolderID: folder.id,
FolderName: folder.name,
Reason: classifyDriveDepthReason(driveDepthErrorCode(err)),
Message: driveDepthErrorMessage(err),
}
}
func driveDepthErrorMessage(err error) string {
var cliErr *CLIError
if errors.As(err, &cliErr) {
var body map[string]any
if json.Unmarshal([]byte(cliErr.Message), &body) == nil {
for _, key := range []string{"errorMsg", "message"} {
if s, ok := body[key].(string); ok && s != "" {
return s
}
}
}
return cliErr.Message
}
return err.Error()
}
+813
View File
@@ -0,0 +1,813 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func driveDepthTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
return cmd
}
func TestCrossPlatformCoverageDriveDepthCancelledError(t *testing.T) {
err := &driveDepthCancelledError{}
if err.Error() != driveDepthCancelledMsg || err.RawStderr() != driveDepthCancelledMsg {
t.Fatalf("messages = %q/%q", err.Error(), err.RawStderr())
}
if err.ExitCode() != 130 {
t.Fatalf("ExitCode = %d, want 130", err.ExitCode())
}
}
func TestCrossPlatformCoverageValidateDriveListDepth(t *testing.T) {
newCmd := func() *cobra.Command { return driveDepthTestCmd() }
if err := validateDriveListDepth(newCmd(), 0); err == nil {
t.Fatal("depth 0 returned nil")
}
if err := validateDriveListDepth(newCmd(), driveDepthMax+1); err == nil {
t.Fatal("depth above max returned nil")
}
shallow := newCmd()
_ = shallow.Flags().Set("cursor", "c1")
_ = shallow.Flags().Set("limit", "5")
if err := validateDriveListDepth(shallow, 1); err != nil {
t.Fatalf("depth 1 with cursor/limit returned %v", err)
}
withCursor := newCmd()
_ = withCursor.Flags().Set("cursor", "c1")
if err := validateDriveListDepth(withCursor, 2); err == nil {
t.Fatal("depth 2 with cursor returned nil")
}
withNextToken := newCmd()
_ = withNextToken.Flags().Set("next-token", "t1")
if err := validateDriveListDepth(withNextToken, 2); err == nil {
t.Fatal("depth 2 with next-token returned nil")
}
withLimit := newCmd()
_ = withLimit.Flags().Set("limit", "5")
if err := validateDriveListDepth(withLimit, 2); err == nil {
t.Fatal("depth 2 with limit returned nil")
}
withMax := newCmd()
_ = withMax.Flags().Set("max", "5")
if err := validateDriveListDepth(withMax, 2); err == nil {
t.Fatal("depth 2 with max returned nil")
}
if err := validateDriveListDepth(newCmd(), 2); err != nil {
t.Fatalf("depth 2 clean returned %v", err)
}
if err := validateDriveListDepth(newCmd(), driveDepthMax); err != nil {
t.Fatalf("depth max clean returned %v", err)
}
}
func TestCrossPlatformCoverageDriveDepthRoutesBuildArgs(t *testing.T) {
pan := newDrivePanDepthRoute()
args := pan.buildArgs(map[string]any{"spaceId": "s1"}, "folder1", "tok")
if args["maxResults"] != float64(driveDepthPageSize) || args["spaceId"] != "s1" ||
args["parentId"] != "folder1" || args["nextToken"] != "tok" {
t.Fatalf("pan args = %#v", args)
}
empty := pan.buildArgs(nil, "", "")
if _, ok := empty["parentId"]; ok {
t.Fatalf("pan empty folder args = %#v", empty)
}
if _, ok := empty["nextToken"]; ok {
t.Fatalf("pan empty token args = %#v", empty)
}
if id := pan.itemID(map[string]any{"fileId": "f1"}); id != "f1" {
t.Fatalf("pan itemID = %q", id)
}
if id := pan.itemID(map[string]any{}); id != "" {
t.Fatalf("pan missing itemID = %q", id)
}
doc := newDocDepthRoute()
dargs := doc.buildArgs(map[string]any{"workspaceId": "w1"}, "node1", "pt")
if dargs["pageSize"] != float64(docDepthPageSize) || dargs["workspaceId"] != "w1" ||
dargs["folderId"] != "node1" || dargs["pageToken"] != "pt" {
t.Fatalf("doc args = %#v", dargs)
}
dempty := doc.buildArgs(nil, "", "")
if _, ok := dempty["folderId"]; ok {
t.Fatalf("doc empty folder args = %#v", dempty)
}
if id := doc.itemID(map[string]any{"nodeId": "n1"}); id != "n1" {
t.Fatalf("doc itemID = %q", id)
}
if id := doc.itemID(map[string]any{}); id != "" {
t.Fatalf("doc missing itemID = %q", id)
}
}
func TestCrossPlatformCoverageParseDriveDepthPage(t *testing.T) {
if items, next, hasMore := parseDriveDepthPage(`{invalid`); items != nil || next != "" || hasMore {
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
}
if items, _, _ := parseDriveDepthPage(`{"foo":1}`); items != nil {
t.Fatalf("missing items key = %#v", items)
}
items, next, hasMore := parseDriveDepthPage(`{"items":[{"name":"a"},42],"nextToken":"t","hasMore":true}`)
if len(items) != 1 || items[0]["name"] != "a" || next != "t" || !hasMore {
t.Fatalf("top-level items = %#v %q %v", items, next, hasMore)
}
items, next, _ = parseDriveDepthPage(`{"result":{"dentryList":[{"name":"b"}],"nextToken":"t2"}}`)
if len(items) != 1 || items[0]["name"] != "b" || next != "t2" {
t.Fatalf("result dentryList = %#v %q", items, next)
}
if items, _, _ := parseDriveDepthPage(`{"result":{"other":1}}`); items != nil {
t.Fatalf("result without items = %#v", items)
}
}
func TestCrossPlatformCoverageDriveDepthListItemsKey(t *testing.T) {
if k := driveDepthListItemsKey(map[string]any{"items": []any{}}); k != "items" {
t.Fatalf("items key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{"dentryList": []any{1}}); k != "dentryList" {
t.Fatalf("dentryList key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{"items": nil}); k != "" {
t.Fatalf("nil items key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{}); k != "" {
t.Fatalf("missing key = %q", k)
}
}
func TestCrossPlatformCoverageParseDocDepthPage(t *testing.T) {
if items, next, hasMore := parseDocDepthPage(`{bad`); items != nil || next != "" || hasMore {
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
}
items, next, hasMore := parseDocDepthPage(`{"nodes":[{"name":"a"},"x"],"nextPageToken":"nt","hasMore":true}`)
if len(items) != 1 || next != "nt" || !hasMore {
t.Fatalf("nodes = %#v %q %v", items, next, hasMore)
}
}
func TestCrossPlatformCoverageMatchDriveNamePattern(t *testing.T) {
if !matchDriveNamePattern("anything", "") {
t.Fatal("empty pattern should match")
}
if !matchDriveNamePattern("report.xlsx", "*.xlsx") || matchDriveNamePattern("report.csv", "*.xlsx") {
t.Fatal("glob match failed")
}
if !matchDriveNamePattern("weekly-report.xlsx", "report") || matchDriveNamePattern("a.txt", "report") {
t.Fatal("substring wrap failed")
}
if !matchDriveNamePattern("a[b", "[") {
t.Fatal("invalid pattern should fall back to substring")
}
}
func TestCrossPlatformCoverageDriveDepthItemClassifiers(t *testing.T) {
if !isDriveDepthFolder(map[string]any{"type": "FOLDER"}) {
t.Fatal("type FOLDER not detected")
}
if !isDriveDepthFolder(map[string]any{"dentryType": "folder"}) {
t.Fatal("dentryType folder not detected")
}
if isDriveDepthFolder(map[string]any{"type": "FILE"}) || isDriveDepthFolder(map[string]any{}) {
t.Fatal("non-folder detected as folder")
}
if !isDocDepthFolder(map[string]any{"nodeType": "Folder"}) {
t.Fatal("doc folder not detected")
}
if isDocDepthFolder(map[string]any{"nodeType": "doc"}) {
t.Fatal("doc detected as folder")
}
if id := driveDepthItemID(map[string]any{"fileId": "f"}); id != "f" {
t.Fatalf("fileId = %q", id)
}
if id := driveDepthItemID(map[string]any{"nodeId": "n"}); id != "n" {
t.Fatalf("nodeId = %q", id)
}
if id := driveDepthItemID(map[string]any{"fileId": ""}); id != "" {
t.Fatalf("empty = %q", id)
}
}
func TestCrossPlatformCoverageDriveDepthUnrecoverable(t *testing.T) {
for _, code := range []string{CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable} {
if !driveDepthUnrecoverable(&CLIError{Code: code}) {
t.Fatalf("%s not unrecoverable", code)
}
}
if driveDepthUnrecoverable(&CLIError{Code: CodeMCPToolError}) {
t.Fatal("tool error marked unrecoverable")
}
if driveDepthUnrecoverable(errors.New("plain")) {
t.Fatal("plain error marked unrecoverable")
}
}
func TestCrossPlatformCoverageDriveDepthErrorCode(t *testing.T) {
if c := driveDepthErrorCode(errors.New("plain")); c != "" {
t.Fatalf("plain = %q", c)
}
if c := driveDepthErrorCode(&CLIError{Message: "not json"}); c != "" {
t.Fatalf("non-JSON = %q", c)
}
for _, tc := range []struct{ body, want string }{
{`{"errorCode":"a.b"}`, "a.b"},
{`{"error_code":"c.d"}`, "c.d"},
{`{"code":"e.f"}`, "e.f"},
{`{"errorCode":""}`, ""},
} {
if c := driveDepthErrorCode(&CLIError{Message: tc.body}); c != tc.want {
t.Fatalf("%s = %q, want %q", tc.body, c, tc.want)
}
}
}
func TestCrossPlatformCoverageClassifyDriveDepthReason(t *testing.T) {
if r := classifyDriveDepthReason(driveDepthRateLimitedCode); r != "rate_limited" {
t.Fatalf("rate limit = %q", r)
}
if r := classifyDriveDepthReason("forbidden.noPermission"); r != "permission_denied" {
t.Fatalf("forbidden = %q", r)
}
if r := classifyDriveDepthReason("other.x"); r != "api_error" {
t.Fatalf("other = %q", r)
}
}
func TestCrossPlatformCoverageDriveDepthErrorMessage(t *testing.T) {
if m := driveDepthErrorMessage(&CLIError{Message: `{"errorMsg":"boom"}`}); m != "boom" {
t.Fatalf("errorMsg = %q", m)
}
if m := driveDepthErrorMessage(&CLIError{Message: `{"message":"m2"}`}); m != "m2" {
t.Fatalf("message = %q", m)
}
if m := driveDepthErrorMessage(&CLIError{Message: "raw text"}); m != "raw text" {
t.Fatalf("raw = %q", m)
}
if m := driveDepthErrorMessage(errors.New("plain boom")); m != "plain boom" {
t.Fatalf("plain = %q", m)
}
}
func TestCrossPlatformCoverageNewDriveDepthError(t *testing.T) {
folder := driveDepthFolder{id: "f1", name: "folder one", depth: 2}
derr := newDriveDepthError(folder, &CLIError{Code: CodeMCPToolError, Message: `{"errorCode":"forbidden.x","errorMsg":"denied"}`})
if derr.Depth != 2 || derr.FolderID != "f1" || derr.FolderName != "folder one" ||
derr.Reason != "permission_denied" || derr.Message != "denied" {
t.Fatalf("driveDepthError = %#v", derr)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func installDepthCaller(t *testing.T, caller *scriptedToolCaller) *bytes.Buffer {
t.Helper()
installScriptedCaller(t, caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
return buf
}
func decodeDepthResult(t *testing.T, buf *bytes.Buffer) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
return out
}
func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
out := installDepthCaller(t, &scriptedToolCaller{})
items := []map[string]any{
{"fileName": "b-file.xlsx", "rel_path": "b", "depth": 1, "fileId": "f2"},
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
got := result["items"].([]any)
if len(got) != 2 {
t.Fatalf("filtered items = %#v", got)
}
if got[0].(map[string]any)["rel_path"] != "a" || got[1].(map[string]any)["rel_path"] != "b" {
t.Fatalf("sort order = %#v", got)
}
if result["maxDepth"] != float64(2) {
t.Fatalf("maxDepth = %#v", result["maxDepth"])
}
if result["truncated"] != false {
t.Fatalf("truncated = %#v", result["truncated"])
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
if items, ok := result["items"].([]any); !ok || len(items) != 0 {
t.Fatalf("nil items = %#v", result["items"])
}
samePath := []map[string]any{
{"name": "dup", "rel_path": "p/dup", "fileId": "z9"},
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
if got[0].(map[string]any)["fileId"] != "a1" {
t.Fatalf("tie-break order = %#v", got)
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
t.Fatal("failing writer returned nil")
}
}
func TestCrossPlatformCoveragePrintDriveDepthDryRun(t *testing.T) {
jsonOut := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true})
if err := printDriveDepthDryRun(newDrivePanDepthRoute(), map[string]any{"spaceId": "s1"}, 3); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
t.Fatalf("json dry-run output: %v", err)
}
if payload["dry_run"] != true || payload["tool"] != "list_files" || payload["maxDepth"] != float64(3) {
t.Fatalf("json dry-run = %#v", payload)
}
installDepthCaller(t, &scriptedToolCaller{format: "table", dry: true})
if err := printDriveDepthDryRun(newDocDepthRoute(), map[string]any{"workspaceId": "w1"}, 2); err != nil {
t.Fatal(err)
}
}
func useDriveDepthArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list", "--depth", "2"}
t.Cleanup(func() { os.Args = old })
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
return decodeDepthResult(t, out), err
}
func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output: %v", err)
}
if payload["dry_run"] != true {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"fY","name":"y.txt","type":"FILE"}]}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2", caller.calls)
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 3 {
t.Fatalf("items = %#v", items)
}
var dirA, nested map[string]any
for _, raw := range items {
item := raw.(map[string]any)
switch item["rel_path"] {
case "dirA":
dirA = item
case "dirA/y.txt":
nested = item
}
}
if dirA == nil || nested == nil {
t.Fatalf("rel_path tree = %#v", items)
}
if dirA["depth"] != float64(1) || dirA["parentId"] != "" {
t.Fatalf("root item = %#v", dirA)
}
if nested["depth"] != float64(2) || nested["parentId"] != "fA" {
t.Fatalf("nested item = %#v", nested)
}
}
func TestCrossPlatformCoverageRunDriveListDepthMaxDepthSkipsEnqueue(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 1, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
if len(result["items"].([]any)) != 1 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthDedup(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER"},
{"fileId":"fA","name":"dirA-copy","type":"FOLDER"},
{"fileId":"","name":"no-id","type":"FOLDER"},
{"fileId":"fB","name":"dirB","type":"FOLDER"}]}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3 (dedup + empty id skipped)", caller.calls)
}
if len(result["items"].([]any)) != 6 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthDocRouteAnomaly(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"nB","name":"folderB","nodeType":"folder"},{"nodeId":"nD","name":"doc1","nodeType":"doc"}],"hasMore":false,"nextPageToken":"ignored"}`},
{text: `{"nodes":[{"nodeId":"nE","name":"inner","nodeType":"doc"}],"hasMore":true}`},
}}
result, err := runDepthBFS(t, caller, newDocDepthRoute(), "root1", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (hasMore=false stops authoritative route)", caller.calls)
}
errs := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("errors = %#v", result["errors"])
}
entry := errs[0].(map[string]any)
if entry["folderId"] != "nB" || entry["reason"] != "api_error" {
t.Fatalf("anomaly entry = %#v", entry)
}
if !strings.Contains(entry["message"].(string), "hasMore=true but nextToken is empty") {
t.Fatalf("anomaly message = %#v", entry)
}
if len(result["items"].([]any)) != 3 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitRetry(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3", caller.calls)
}
if errs := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if len(result["items"].([]any)) != 2 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t *testing.T) {
useDriveDepthArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"page1.txt","type":"FILE"}],"nextToken":"p2"}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
{text: `{"items":[{"fileId":"f2","name":"page2.txt","type":"FILE"}]}`},
}}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
t.Fatalf("calls = %d, want 3", len(caller.calls))
}
if tok, _ := caller.calls[2]["nextToken"].(string); tok != "p2" {
t.Fatalf("retry call args = %#v, want resume with nextToken=p2", caller.calls[2])
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 2 {
t.Fatalf("items = %#v, want 2 without duplicates", items)
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitExhausted(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3 (one retry)", caller.calls)
}
errs := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("errors = %#v", errs)
}
entry := errs[0].(map[string]any)
if entry["reason"] != "rate_limited" || entry["folderId"] != "fA" {
t.Fatalf("rate limit entry = %#v", entry)
}
}
func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("root failure returned nil")
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (single retry then fail)", caller.calls)
}
}
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("unrecoverable returned nil")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("err = %T %v", err, err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 2 {
t.Fatalf("partial items = %#v", result["items"])
}
if len(result["errors"].([]any)) != 1 {
t.Fatalf("errors = %#v", result["errors"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[],"nextToken":"t","hasMore":true}`},
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
maxPages := driveDepthMaxItems/driveDepthPageSize + 1
if caller.calls != maxPages {
t.Fatalf("calls = %d, want %d", caller.calls, maxPages)
}
}
func TestCrossPlatformCoverageRunDriveListDepthTruncation(t *testing.T) {
useDriveDepthArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE"}`, i, i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if len(result["items"].([]any)) != driveDepthMaxItems {
t.Fatalf("items len = %d", len(result["items"].([]any)))
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{}
out := installDepthCaller(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if result["truncated"] != true {
t.Fatalf("cancelled result = %#v", result)
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
}}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPatternFilter(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"fY","name":"keep.xlsx","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "*.xlsx")
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["name"] != "keep.xlsx" {
t.Fatalf("filtered = %#v", items)
}
}
type cancelOnCallCaller struct {
scriptedToolCaller
cancel context.CancelFunc
}
func (c *cancelOnCallCaller) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.cancel()
return c.scriptedToolCaller.CallTool(ctx, productID, toolName, args)
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *testing.T) {
useDriveDepthArgs(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
route := newDrivePanDepthRoute()
baseParse := route.parsePage
route.parsePage = func(text string) ([]map[string]any, string, bool) {
cancel()
return baseParse(text)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}],"nextToken":"more"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 1 || result["truncated"] != true {
t.Fatalf("cancelled partial = %#v", result)
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T) {
useDriveDepthArgs(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
caller := &cancelOnCallCaller{
scriptedToolCaller: scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
}},
cancel: cancel,
}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if result["truncated"] != true {
t.Fatalf("cancelled result = %#v", result)
}
}
+227
View File
@@ -0,0 +1,227 @@
package helpers
import (
"context"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type depthArgsRecordingCaller struct {
steps []scriptedToolStep
index int
calls []map[string]any
}
func (c *depthArgsRecordingCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, args)
index := c.index
if index >= len(c.steps) {
index = len(c.steps) - 1
}
c.index++
return textToolResult(c.steps[index].text), nil
}
func (*depthArgsRecordingCaller) Format() string { return "json" }
func (*depthArgsRecordingCaller) DryRun() bool { return false }
func (*depthArgsRecordingCaller) Fields() string { return "" }
func (*depthArgsRecordingCaller) JQ() string { return "" }
func executeDriveCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDriveListVersionsMode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "5", "--cursor", "cur-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "list_file_versions" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["maxResults"] != 5 || call.args["nextCursor"] != "cur-1" {
t.Fatalf("args = %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveListVersionsModeRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "list", "--versions")
if err == nil {
t.Fatal("versions without --node returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListWorkspaceDepthRoutesDocBFS(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller,
"list", "--workspace", "ws-1", "--depth", "2", "--node", "folder-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["workspaceId"] != "ws-1" || args["folderId"] != "folder-1" || args["pageSize"] != float64(docDepthPageSize) {
t.Fatalf("doc BFS args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListWorkspaceDepthRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--workspace", "ws-1", "--depth", "2", "--node", "12345")
if err == nil {
t.Fatal("numeric doc folder returned nil")
}
}
func TestCrossPlatformCoverageDriveListPanDepthBuildsBaseArgs(t *testing.T) {
useDriveDepthArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--depth", "2", "--space-id", "sp-1", "--order-by", "name", "--order", "asc", "--thumbnail", "--folder", "root-folder")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["spaceId"] != "sp-1" || args["orderBy"] != "name" || args["order"] != "asc" ||
args["withThumbnail"] != true || args["parentId"] != "root-folder" || args["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("pan BFS args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListPanDepthRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--depth", "2", "--folder", "12345")
if err == nil {
t.Fatal("numeric drive folder returned nil")
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRun(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerYesRequiresRecursive(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--yes")
if err == nil || !strings.Contains(err.Error(), "--recursive is required") {
t.Fatalf("err = %v, want --recursive required", err)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerWorkspaceTarget(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--workspace", "ws-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--recursive", "--yes")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "doc" || call.toolName != "transfer_owner" {
t.Fatalf("call = %#v", call)
}
if call.args["workspaceId"] != "ws-1" || call.args["newOwnerId"] != "user-1" ||
call.args["reserveOldOwnerRole"] != "EDITOR" || call.args["recursiveChange"] != true {
t.Fatalf("args = %#v", call.args)
}
if _, ok := call.args["nodeId"]; ok {
t.Fatalf("unexpected nodeId in %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveCoverRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "cover")
if err == nil {
t.Fatal("cover without --node returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveRevertDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"revert", "--node", "node-1", "--version", "3")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined revert calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerRejectsBothTargets(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--workspace", "ws-1", "--new-owner", "user-1")
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err = %v, want mutual exclusion", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
+263
View File
@@ -0,0 +1,263 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// executeDriveCommandCapture 执行 drive 命令并捕获 deps.Out 的 stdout 输出,
// 供需要断言 JSON 输出形态的测试使用(等价于 installDepthCaller + Execute)。
func executeDriveCommandCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
deps.Out.errW = io.Discard
root := newDriveCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return buf, err
}
// ── drive permission apply:确认门禁与帮助文案契约一致 ──
func TestCrossPlatformCoverageDrivePermissionApplyDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "apply", "--node", "node-1", "--role", "READER", "--users", "u1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined apply calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDrivePermissionApplyYesProceeds(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "apply", "--node", "node-1", "--role", "reader", "--users", "u1,u2", "--yes")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "apply_permission" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["roleId"] != "READER" {
t.Fatalf("args = %#v", call.args)
}
if !reflect.DeepEqual(call.args["receivers"], []string{"u1", "u2"}) {
t.Fatalf("receivers = %#v", call.args["receivers"])
}
}
func TestCrossPlatformCoverageDrivePermissionApplyDryRunNoPromptNoCall(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
root := newDriveCommand()
promptOut := &bytes.Buffer{}
root.SetErr(promptOut)
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "apply", "--node", "node-1", "--role", "READER", "--users", "u1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run apply calls = %#v, want none", caller.calls)
}
if strings.Contains(promptOut.String(), "Confirm action") {
t.Fatalf("dry-run prompted for confirmation: %q", promptOut.String())
}
}
// ── drive permission transfer-owner:dry-run JSON 输出且校验先于 dry-run ──
func TestCrossPlatformCoverageDriveTransferOwnerDryRunJSONNode(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run tool calls = %d, want none", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out.String())
}
if payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("payload = %#v", payload)
}
if payload["operation"] != "转交所有者" || payload["newOwnerId"] != "user-1" || payload["nodeId"] != "node-1" {
t.Fatalf("payload = %#v", payload)
}
if _, ok := payload["workspaceId"]; ok {
t.Fatalf("unexpected workspaceId in %#v", payload)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunJSONWorkspace(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--workspace", "ws-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out.String())
}
if payload["workspaceId"] != "ws-1" || payload["newOwnerId"] != "user-1" {
t.Fatalf("payload = %#v", payload)
}
if _, ok := payload["nodeId"]; ok {
t.Fatalf("unexpected nodeId in %#v", payload)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunYesValidatesFirst(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--yes", "--dry-run")
if err == nil || !strings.Contains(err.Error(), "--reserve-role is required") {
t.Fatalf("err = %v, want --reserve-role required even under --dry-run", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunNonJSON(t *testing.T) {
caller := &scriptedToolCaller{format: "table", dry: true}
_, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run tool calls = %d, want none", caller.calls)
}
}
// ── drive list:--versions 与 --depth/--pattern 的交互 ──
func TestCrossPlatformCoverageDriveListVersionsRejectsDepth(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--depth", "2", "--limit", "10")
if err == nil || !strings.Contains(err.Error(), "--depth") {
t.Fatalf("err = %v, want explicit --versions/--depth conflict", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsRejectsPattern(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--pattern", "x")
if err == nil || !strings.Contains(err.Error(), "--pattern") {
t.Fatalf("err = %v, want explicit --versions/--pattern conflict", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsAllowsDepthOne(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--depth", "1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_file_versions" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsWithLimit(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "10")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "list_file_versions" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["maxResults"] != 10 {
t.Fatalf("args = %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveDownloadVersionDryRun(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"download-version", "--node", "node-1", "--version", "3", "--output", "./x.pdf", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveDownloadVersionDirectoryOutput(t *testing.T) {
SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return nil })
t.Cleanup(func() { SetHTTPGetFile(nil) })
dir := t.TempDir()
for _, resp := range []string{
`{"downloadUrl":"https://oss.test/get/report_v3.pdf","fileName":"报告v3.pdf"}`,
`{"downloadUrl":"https://oss.test/get/inferred_v3.pdf"}`,
} {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: resp}}}
installScriptedCaller(t, caller)
root := newDriveCommand()
root.PersistentFlags().Bool("dry-run", false, "")
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"download-version", "--node", "node-1", "--version", "3", "--output", dir})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
}
@@ -0,0 +1,168 @@
package helpers
import (
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageDriveStarCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "add",
args: []string{"star", "add", "--node", "node-1"},
want: guardedMutationCall{
productID: "drive",
toolName: "mark_star",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "add with url value passed through",
args: []string{"star", "add", "--node", "https://alidocs.dingtalk.com/i/nodes/node-2"},
want: guardedMutationCall{
productID: "drive",
toolName: "mark_star",
args: map[string]any{"nodeId": "https://alidocs.dingtalk.com/i/nodes/node-2"},
},
},
{
name: "remove",
args: []string{"star", "remove", "--node", "node-3"},
want: guardedMutationCall{
productID: "drive",
toolName: "unmark_star",
args: map[string]any{"nodeId": "node-3"},
},
},
{
name: "remove rm alias",
args: []string{"star", "rm", "--node", "node-4"},
want: guardedMutationCall{
productID: "drive",
toolName: "unmark_star",
args: map[string]any{"nodeId": "node-4"},
},
},
{
name: "list without filters",
args: []string{"star", "list"},
want: guardedMutationCall{
productID: "drive",
toolName: "get_star_list",
args: map[string]any{},
},
},
{
name: "list with all filters",
args: []string{
"star", "list",
"--limit", "10",
"--cursor", "cur-1",
"--order-by", "createTime",
"--sort", "desc",
"--resource-types", "DENTRY,WORKSPACE",
"--content-types", "doc,sheet",
},
want: guardedMutationCall{
productID: "drive",
toolName: "get_star_list",
args: map[string]any{
"limit": 10,
"cursor": "cur-1",
"orderBy": "createTime",
"sortType": "desc",
"supportResourceTypes": []string{"DENTRY", "WORKSPACE"},
"contentTypes": []string{"doc", "sheet"},
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, test.args...)
if err != nil {
t.Fatalf("drive %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageDriveStarRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "add without node",
args: []string{"star", "add"},
wantErr: "--node",
},
{
name: "remove without node",
args: []string{"star", "remove"},
wantErr: "--node",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageDriveStarListRecordsRawArgs(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"nodeId":"n1","name":"doc1"}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller,
"star", "list", "--limit", "5", "--cursor", "cur-9", "--sort", "asc")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["limit"] != 5 || args["cursor"] != "cur-9" || args["sortType"] != "asc" {
t.Fatalf("star list raw args = %#v", args)
}
if _, ok := args["orderBy"]; ok {
t.Fatalf("unexpected orderBy in %#v", args)
}
}
func TestCrossPlatformCoverageDriveStarAddScriptedSuccess(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"success":true}`},
}}
installScriptedCaller(t, caller)
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"star", "add", "--node", "node-1"})
if err := root.Execute(); err != nil {
t.Fatalf("star add returned error: %v", err)
}
if caller.calls != 1 {
t.Fatalf("tool calls = %d, want 1", caller.calls)
}
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
file := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
meta, err := BuildConversationLocalFileMeta(file, "", "")
if err != nil {
t.Fatal(err)
}
if meta.FileName != "report.txt" || meta.FileSize != 7 {
t.Fatalf("meta = %#v", meta)
}
content, err := BuildConversationFileContent(11, 22, meta)
if err != nil || !strings.Contains(content, `"dentryId":11`) {
t.Fatalf("content = %q, %v", content, err)
}
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
if err != nil || dentryID != 11 || spaceID != 22 {
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
}
oldPut := httpPutFile
t.Cleanup(func() { httpPutFile = oldPut })
var putPath string
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
putPath = localPath
if size != 7 {
t.Errorf("upload size = %d", size)
}
return nil
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
}}
installScriptedCaller(t, caller)
commit, err := UploadConversationLocalFile(
context.Background(),
map[string]any{"openConversationId": "cid"},
meta,
"uuid",
)
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
}
}
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
for tool, want := range map[string]bool{
" get_conversation ": true,
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
} {
if got := IsReadToolName(tool); got != want {
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
}
}
caller := &helpersReadCaller{
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
readResult: textToolResult(`{"success":true}`),
}
installHelpersCoreDeps(t, caller)
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the read helper boundary")
}
if caller.readCalls != 0 || caller.calls != 0 {
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
}
}
+22
View File
@@ -156,6 +156,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
ctx := context.Background()
if !IsReadToolName(toolName) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
}
}
if deps == nil || deps.Caller == nil {
return "", &CLIError{
Code: CodeMCPToolError,
@@ -176,6 +182,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// IsReadToolName is the fail-closed naming contract for the dry-run read
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
// future direct helper caller cannot accidentally route a write tool through
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(toolName, prefix) {
return true
}
}
return false
}
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
+6 -5
View File
@@ -144,11 +144,12 @@ func (f *Formatter) PrintTable(headers []string, rows [][]string) {
fmt.Fprintf(f.w, "共 %d 条\n", len(rows))
}
func (f *Formatter) PrintSuccess(msg string) { fmt.Fprintf(f.w, "[OK] %s\n", msg) }
func (f *Formatter) PrintError(msg string) { fmt.Fprintf(f.w, "[ERROR] %s\n", msg) }
func (f *Formatter) PrintWarning(msg string) { fmt.Fprintf(f.errW, "[WARN] %s\n", msg) }
func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
func (f *Formatter) PrintSuccess(msg string) { fmt.Fprintf(f.w, "[OK] %s\n", msg) }
func (f *Formatter) PrintError(msg string) { fmt.Fprintf(f.w, "[ERROR] %s\n", msg) }
func (f *Formatter) PrintWarning(msg string) { fmt.Fprintf(f.errW, "[WARN] %s\n", msg) }
func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", msg) }
func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
func (f *Formatter) PrintKeyValue(key, value string) {
fmt.Fprintf(f.w, "%-16s%s\n", key+":", value)
+3
View File
@@ -151,6 +151,7 @@ func newSheetCommand() *cobra.Command {
// Add all to root
root.AddCommand(standaloneCmds...)
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
root.AddCommand(newSheetVersionCmd(), newSheetCommentCmd(), newSheetFormulaVerifyCmd())
// This is the reviewed runtime counterpart of the final Sheet Schema
// confirmation=user_required set. It is intentionally command-local: there
@@ -175,6 +176,8 @@ func newSheetCommand() *cobra.Command {
{path: "filter-view delete-criteria", operation: "删除筛选视图列条件", targetHint: "文档、工作表、筛选视图和列"},
{path: "range batch-clear", operation: "批量清除工作表区域", targetHint: "文档、工作表、清除范围和清除类型"},
{path: "range move-to", operation: "移动工作表区域", targetHint: "源工作表范围和目标位置"},
{path: "version revert", operation: "回滚表格版本", targetHint: "文档和目标版本号"},
{path: "comment delete", operation: "删除单元格评论", targetHint: "文档和评论 commentKey"},
}
for _, guard := range confirmationGuards {
attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint)
+172
View File
@@ -0,0 +1,172 @@
package helpers
import (
"github.com/spf13/cobra"
)
func newSheetCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
Use: "comment",
Short: "表格评论 / 单元格评论管理",
Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`,
RunE: groupRunE,
}
commentListCmd := &cobra.Command{
Use: "list",
Short: "查询表格评论列表",
Example: ` dws sheet comment list --node <SHEET_ID>
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
toolArgs["pageSize"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
toolArgs["nextToken"] = v
}
if v, _ := cmd.Flags().GetString("resolve-status"); v != "" {
toolArgs["resolveStatus"] = v
}
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
toolArgs["sheetId"] = v
}
if v, _ := cmd.Flags().GetString("range"); v != "" {
toolArgs["range"] = v
}
return callMCPToolOnServer("doc-comment", "list_sheet_comments", toolArgs)
},
}
commentListCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentListCmd.Flags().Int("limit", 50, "每页返回的评论数量,默认 50,最大 50")
commentListCmd.Flags().String("cursor", "", "分页游标")
commentListCmd.Flags().String("resolve-status", "", "按解决状态过滤: resolved / unresolved")
commentListCmd.Flags().String("sheet-id", "", "工作表 ID 或名称(与 --range 一起按单元格过滤)")
commentListCmd.Flags().String("range", "", "单元格位置 A1 表示法(与 --sheet-id 一起按单元格过滤)")
commentCreateCmd := &cobra.Command{
Use: "create",
Short: "创建单元格评论",
Example: ` dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题"
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请确认" --mention uid1,uid2`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "sheet-id", "range"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"content": mustGetFlag(cmd, "content"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
}
return callMCPToolOnServer("doc-comment", "create_sheet_comment", toolArgs)
},
}
commentCreateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentCreateCmd.Flags().String("content", "", "评论内容 (必填)")
commentCreateCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
commentCreateCmd.Flags().String("range", "", "单元格位置 A1 表示法 (必填)")
commentCreateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
commentReplyCmd := &cobra.Command{
Use: "reply",
Short: "回复单元格评论",
Example: ` dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实"
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "comment-key"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"content": mustGetFlag(cmd, "content"),
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
}
return callMCPToolOnServer("doc-comment", "reply_comment", toolArgs)
},
}
commentReplyCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentReplyCmd.Flags().String("content", "", "回复内容 (必填)")
commentReplyCmd.Flags().String("comment-key", "", "被回复评论的 commentKey (必填)")
commentReplyCmd.Flags().Bool("emoji", false, "作为表情贴图回复")
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
commentUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新单元格评论",
Example: ` dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "comment-key"); err != nil {
return err
}
return callMCPToolOnServer("doc-comment", "update_comment", map[string]any{
"nodeId": nodeID,
"commentKey": mustGetFlag(cmd, "comment-key"),
"content": mustGetFlag(cmd, "content"),
})
},
}
commentUpdateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey (必填)")
commentUpdateCmd.Flags().String("content", "", "更新后的评论内容 (必填)")
commentDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除单元格评论",
Example: ` dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
return err
}
commentKey := mustGetFlag(cmd, "comment-key")
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
"nodeId": nodeID,
"commentKey": commentKey,
})
},
}
commentDeleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey (必填)")
for _, c := range []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd)
return commentCmd
}
+156
View File
@@ -0,0 +1,156 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
)
func newSheetFormulaVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "formula-verify",
Short: "校验表格公式错误",
Long: `扫描钉钉电子表格中已落表的公式单元格,按计算结果错误类型聚合返回错误数量、位置和样本。
不指定 --sheet-id / --range / --targets 时默认扫描整本表格的全部工作表。`,
Example: ` dws sheet formula-verify --node NODE_ID
dws sheet formula-verify --node NODE_ID --sheet-id Sheet1 --range A1:D100
dws sheet formula-verify --node NODE_ID --targets '[{"sheetId":"Sheet1","range":"A1:D100"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
}
targets, err := formulaVerifyTargetsFromFlags(cmd)
if err != nil {
return err
}
if len(targets) > 0 {
toolArgs["targets"] = targets
}
if cmd.Flags().Changed("max-locations-per-error") {
v, _ := cmd.Flags().GetInt("max-locations-per-error")
if v <= 0 {
return fmt.Errorf("--max-locations-per-error 必须是正整数")
}
toolArgs["maxLocationsPerError"] = v
}
if cmd.Flags().Changed("max-cells") {
v, _ := cmd.Flags().GetInt("max-cells")
if v <= 0 {
return fmt.Errorf("--max-cells 必须是正整数")
}
toolArgs["maxCells"] = v
}
exitOnError, _ := cmd.Flags().GetBool("exit-on-error")
return callMCPToolFormulaVerify(toolArgs, exitOnError)
},
}
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称;与 --range 组成单个扫描目标")
cmd.Flags().String("range", "", "A1 范围;需与 --sheet-id 配合使用")
cmd.Flags().String("targets", "", `扫描目标 JSON 数组、@文件路径 或 - 表示 stdin;每项 {"sheetId":"Sheet1","range":"A1:D100"}`)
cmd.Flags().Int("max-locations-per-error", 0, "每种错误类型最多返回的位置数")
cmd.Flags().Int("max-cells", 0, "最多扫描的单元格数")
cmd.Flags().Bool("exit-on-error", false, "发现公式错误时返回非 0 退出码,便于 CI/自动化使用")
return cmd
}
// callMCPToolFormulaVerify 与常规打印路径的差异:--exit-on-error 需要读取
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
}
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
if err != nil {
return err
}
if text == "" {
return nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
deps.Out.PrintRaw(text)
return nil
}
if parsed == nil {
return fmt.Errorf("formula_verify returned empty result")
}
if err := deps.Out.PrintJSON(parsed); err != nil {
return err
}
if exitOnError && formulaVerifyHasErrors(parsed) {
return fmt.Errorf("formula errors found")
}
return nil
}
func formulaVerifyHasErrors(parsed map[string]any) bool {
result := formulaVerifyResultObject(parsed)
status := strings.ToLower(strings.TrimSpace(fmt.Sprint(result["status"])))
if status == "errors_found" {
return true
}
totalErrors, ok := nonNegativeJSONInt(result["totalErrors"])
return ok && totalErrors > 0
}
func formulaVerifyResultObject(parsed map[string]any) map[string]any {
if result, ok := parsed["result"].(map[string]any); ok {
return result
}
return parsed
}
func formulaVerifyTargetsFromFlags(cmd *cobra.Command) ([]map[string]any, error) {
sheetID, _ := cmd.Flags().GetString("sheet-id")
rangeStr, _ := cmd.Flags().GetString("range")
if v, _ := cmd.Flags().GetString("targets"); v != "" {
if strings.TrimSpace(sheetID) != "" || strings.TrimSpace(rangeStr) != "" {
return nil, fmt.Errorf("--targets 不能与 --sheet-id 或 --range 同时使用")
}
return parseFormulaVerifyTargets(cmd, v)
}
if sheetID == "" && rangeStr != "" {
return nil, fmt.Errorf("--range 必须与 --sheet-id 配合使用")
}
if sheetID != "" {
t := map[string]any{"sheetId": sheetID}
if rangeStr != "" {
t["range"] = rangeStr
}
return []map[string]any{t}, nil
}
return nil, nil
}
func parseFormulaVerifyTargets(cmd *cobra.Command, raw string) ([]map[string]any, error) {
data := raw
if strings.HasPrefix(raw, "@") {
filePath := strings.TrimPrefix(raw, "@")
content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取 --targets 文件失败: %w", err)
}
data = string(content)
} else if raw == "-" {
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("读取 stdin 失败: %w", err)
}
data = string(content)
}
var targets []map[string]any
if err := json.Unmarshal([]byte(data), &targets); err != nil {
return nil, fmt.Errorf("--targets JSON 解析失败: %w", err)
}
return targets, nil
}
@@ -0,0 +1,175 @@
package helpers
import (
"os"
"path/filepath"
"strings"
"testing"
)
func executeFormulaVerify(t *testing.T, caller *scriptedToolCaller, stdin *strings.Reader, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
if stdin != nil {
cmd.SetIn(stdin)
}
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageSheetFormulaVerifyRejectsNonPositiveLimits(t *testing.T) {
if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--max-locations-per-error", "0"); err == nil {
t.Fatal("max-locations-per-error 0 returned nil")
}
if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--max-cells", "-1"); err == nil {
t.Fatal("max-cells -1 returned nil")
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeFormulaVerify(t, caller, nil,
"--node", "n1", "--max-locations-per-error", "3", "--max-cells", "100",
"--targets", `[{"sheetId":"Sheet1","range":"A1:D10"}]`)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "targets.json")
if err := os.WriteFile(path, []byte(`[{"sheetId":"Sheet1"}]`), 0o600); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--targets", "@"+path); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFileMissing(t *testing.T) {
err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--targets", "@/nonexistent/targets.json")
if err == nil || !strings.Contains(err.Error(), "读取 --targets 文件失败") {
t.Fatalf("err = %v, want file read failure", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromStdin(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeFormulaVerify(t, caller, strings.NewReader(`[{"sheetId":"S1"}]`),
"--node", "n1", "--targets", "-"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsStdinFailure(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetIn(failingReader{})
cmd.SetArgs([]string{"--node", "n1", "--targets", "-"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
t.Fatalf("err = %v, want stdin failure", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsConflict(t *testing.T) {
for _, extra := range [][]string{
{"--sheet-id", "Sheet1"},
{"--range", "A1:B2"},
} {
args := append([]string{"--node", "n1", "--targets", `[{"sheetId":"S1"}]`}, extra...)
err := executeFormulaVerify(t, &scriptedToolCaller{}, nil, args...)
if err == nil || !strings.Contains(err.Error(), "--targets 不能与 --sheet-id 或 --range 同时使用") {
t.Fatalf("args %v err = %v, want conflict error", extra, err)
}
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyExitOnError(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"ERRORS_FOUND","totalErrors":2}`},
}}
err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error")
if err == nil || !strings.Contains(err.Error(), "formula errors found") {
t.Fatalf("err = %v, want formula errors found", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"status":"partial","totalErrors":1}}`},
}}
err = executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error")
if err == nil || !strings.Contains(err.Error(), "formula errors found") {
t.Fatalf("nested totalErrors err = %v, want formula errors found", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"clean","totalErrors":0}`},
}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatalf("clean result err = %v", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"errors_found"}`},
}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1"); err != nil {
t.Fatalf("errors without --exit-on-error err = %v", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyPayloadEdges(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `plain text result`}}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatalf("non-JSON payload err = %v", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `null`}}}
err := executeFormulaVerify(t, caller, nil, "--node", "n1")
if err == nil || !strings.Contains(err.Error(), "empty result") {
t.Fatalf("null payload err = %v, want empty result", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"forbidden.x","errorMsg":"denied"}`}}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1"); err == nil {
t.Fatal("business error returned nil")
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"status":"clean"}`}}}
installScriptedCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--node", "n1"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("print failure err = %v, want write failed", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyDryRunShortCircuit(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d, want 0 (preview only)", caller.calls)
}
}
@@ -0,0 +1,40 @@
package helpers
import (
"io"
"os"
"testing"
)
func TestCrossPlatformCoverageSheetInfoIncludePassthrough(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = oldArgs })
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"sheetId":"s1"}`},
}}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newSheetCommand()
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "")
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"info", "--node", "n1", "--sheet-id", "Sheet1", "--include", "row_heights,col_widths"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
include, _ := caller.calls[0]["include"].([]string)
if len(include) != 2 || include[0] != "row_heights" || include[1] != "col_widths" {
t.Fatalf("include = %#v", caller.calls[0]["include"])
}
}
@@ -0,0 +1,298 @@
package helpers
import (
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "list minimal",
args: []string{"comment", "list", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "list_sheet_comments",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list with cell and status filters",
args: []string{
"comment", "list",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "A2",
"--resolve-status", "unresolved",
"--limit", "10",
"--cursor", "cur-1",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "list_sheet_comments",
args: map[string]any{
"nodeId": "node-1",
"sheetId": "Sheet1",
"range": "A2",
"resolveStatus": "unresolved",
"pageSize": 10,
"nextToken": "cur-1",
},
},
},
{
name: "create with mention parsing",
args: []string{
"comment", "create",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "A2",
"--content", "check this",
"--mention", "uid1, uid2 ,,uid3",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "create_sheet_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "check this",
"sheetId": "Sheet1",
"range": "A2",
"mentionedUserIds": []string{"uid1", "uid2", "uid3"},
},
},
},
{
name: "create without mention",
args: []string{
"comment", "create",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "B3",
"--content", "plain",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "create_sheet_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "plain",
"sheetId": "Sheet1",
"range": "B3",
},
},
},
{
name: "reply with emoji and mention",
args: []string{
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--emoji",
"--mention", "uid1",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
},
},
},
{
name: "reply plain text",
args: []string{
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-2",
"--content", "confirmed",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "confirmed",
"replyCommentKey": "ck-2",
},
},
},
{
name: "update",
args: []string{
"comment", "update",
"--node", "node-1",
"--comment-key", "ck-3",
"--content", "revised",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "update_comment",
args: map[string]any{
"nodeId": "node-1",
"commentKey": "ck-3",
"content": "revised",
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err != nil {
t.Fatalf("sheet %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageSheetCommentRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "list without node",
args: []string{"comment", "list"},
wantErr: "--node",
},
{
name: "create without content sheet-id range",
args: []string{"comment", "create", "--node", "node-1"},
wantErr: "--content, --sheet-id, --range",
},
{
name: "reply without comment-key",
args: []string{"comment", "reply", "--node", "node-1", "--content", "text"},
wantErr: "--comment-key",
},
{
name: "update without content",
args: []string{"comment", "update", "--node", "node-1", "--comment-key", "ck-1"},
wantErr: "--content",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageSheetVersionCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "save",
args: []string{"version", "save", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "save_doc_version",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list minimal",
args: []string{"version", "list", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list with pagination pass-through",
args: []string{"version", "list", "--node", "node-1", "--limit", "10", "--cursor", "cur-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{
"nodeId": "node-1",
"maxResults": 10,
"nextCursor": "cur-1",
},
},
},
{
name: "list ls alias",
args: []string{"version", "ls", "--node", "node-2"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{"nodeId": "node-2"},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err != nil {
t.Fatalf("sheet %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageSheetVersionRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "save without node",
args: []string{"version", "save"},
wantErr: "--node",
},
{
name: "list without node",
args: []string{"version", "list"},
wantErr: "--node",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
+95
View File
@@ -0,0 +1,95 @@
package helpers
import (
"fmt"
"github.com/spf13/cobra"
)
func newSheetVersionCmd() *cobra.Command {
versionCmd := &cobra.Command{
Use: "version",
Short: "表格历史版本管理",
Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
versionSaveCmd := &cobra.Command{
Use: "save",
Short: "手动保存表格版本快照",
Example: ` dws sheet version save --node SHEET_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("doc", "save_doc_version", map[string]any{
"nodeId": nodeID,
})
},
}
versionSaveCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查看表格历史版本列表",
Example: ` dws sheet version list --node SHEET_ID
dws sheet version list --node SHEET_ID --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPToolOnServer("doc", "list_doc_versions", toolArgs)
},
}
versionListCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionListCmd.Flags().Int("limit", 0, "返回版本数量上限")
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚表格到指定版本",
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if !cmd.Flags().Changed("version") {
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
"version": version,
})
},
}
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
c.Flags().String("doc-id", "", "")
c.Flags().String("file-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
return versionCmd
}
+6 -1
View File
@@ -69,7 +69,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
不传 --sheet-id 时默认返回第一个工作表。`,
Example: ` dws sheet info --node NODE_ID
dws sheet info --node NODE_ID --sheet-id SHEET_ID
dws sheet info --node NODE_ID --sheet-id "Sheet1"`,
dws sheet info --node NODE_ID --sheet-id "Sheet1"
dws sheet info --node NODE_ID --sheet-id SHEET_ID --include row_heights,col_widths`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
@@ -77,11 +78,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
toolArgs["sheetId"] = v
}
if v, _ := cmd.Flags().GetStringSlice("include"); len(v) > 0 {
toolArgs["include"] = v
}
return callMCPToolSheetInfo(toolArgs)
},
}
infoCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
infoCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (不传则返回第一个工作表)")
infoCmd.Flags().StringSlice("include", nil, "可选扩展信息,逗号分隔;支持 groups / row_heights / col_widths / hidden_rows / hidden_cols / frozen")
newCmd := &cobra.Command{
Use: "new",
+6 -4
View File
@@ -55,10 +55,12 @@ Host-owned PAT 开关:
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
CLI 侧不再拉起任何本地浏览器 / 轮询。
服务端路由标签 claw-type(开源构建硬编码):
开源构建在所有出站 MCP 请求上恒定注入 claw-type: openClaw,
与 DINGTALK_AGENT / 宿主环境解耦,与历史 main 行为一致。
hostControl.clawType 也会回填该值,便于宿主侧审计/路由。
服务端 Agent 产品标签 claw-type:
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
+335 -86
View File
@@ -14,9 +14,10 @@
package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
@@ -318,7 +319,7 @@ func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool)
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -525,39 +526,26 @@ var MessagesMget = shortcut.Shortcut{
Command: "+messages-mget",
Product: "im",
Description: "根据消息 ID 批量查询消息(最多 50 条)",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
if rt.Bool("download-resources") {
if err := validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir"); err != nil {
return err
}
}
return nil
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
@@ -587,81 +575,194 @@ var MessagesMget = shortcut.Shortcut{
"messages": messages,
}
if rt.Bool("download-resources") {
ledger, err := downloadMgetResources(rt, rawMessages)
if err != nil {
return err
}
payload["resourceDownloads"] = ledger
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
}
return rt.Output(payload)
},
}
func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]any) (map[string]any, error) {
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
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: "允许覆盖同名资源文件(默认拒绝)"},
}
}
// MessageResourceDownloadConstraints publishes the shared safe-output rule.
func MessageResourceDownloadConstraints() []shortcut.Constraint {
return []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
}}
}
// ValidateMessageResourceDownload validates the output path only when the
// caller opts into local writes.
func ValidateMessageResourceDownload(rt *shortcut.RuntimeContext) error {
if !rt.Bool("download-resources") {
return nil
}
return validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir")
}
// DownloadMessageResources downloads every unique message resource reference
// and returns a per-resource success/failure ledger. A fallback conversation
// ID lets group/thread list commands supply context when a mediaId item's lower
// response omits it; fileId resources route through the existing drive leaf.
func DownloadMessageResources(
rt *shortcut.RuntimeContext,
messages []map[string]any,
fallbackConversationID string,
) map[string]any {
resources := make([]map[string]any, 0)
for _, message := range messages {
resources = append(resources, chatmsg.Resources(message)...)
resources = append(resources, chatmsg.ResourcesDeep(message)...)
}
discoveredCount := len(resources)
uniqueResources := make([]map[string]any, 0, len(resources))
seen := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
if messageID == "<nil>" {
messageID = ""
}
key := strings.ToLower(resourceType) + "\x00" + resourceID
if strings.EqualFold(resourceType, "mediaId") {
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
key += "\x00" + messageID + "\x00" + conversationID
}
if resourceID != "" && resourceID != "<nil>" {
if seen[key] {
continue
}
seen[key] = true
}
uniqueResources = append(uniqueResources, resource)
}
resources = uniqueResources
if rt.DryRun() {
return map[string]any{
"dryRun": true,
"requestedCount": len(resources),
"resources": resources,
}, nil
"dryRun": true,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"resources": resources,
}
}
if len(resources) == 0 {
return map[string]any{
"ok": true,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": 0,
"deduplicatedCount": discoveredCount,
"downloadedCount": 0,
"failedCount": 0,
"downloads": []map[string]any{},
"failures": []map[string]any{},
}
}
cwd, err := resourceGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
return map[string]any{
"ok": false,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": 0,
"failedCount": len(resources),
"downloads": []map[string]any{},
"failures": []map[string]any{{
"stage": "output-directory",
"affectedCount": len(resources),
"error": fmt.Sprintf("读取工作目录失败: %v", err),
}},
}
}
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`) + string(os.PathSeparator)
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`)
downloads := make([]map[string]any, 0, len(resources))
failures := make([]map[string]any, 0)
downloadedNames := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
if download["ready"] != true || resourceID == "" || messageID == "" || conversationID == "" {
if messageID == "<nil>" {
messageID = ""
}
if conversationID == "" || conversationID == "<nil>" {
conversationID = strings.TrimSpace(fallbackConversationID)
}
missingMediaContext := resourceType == "mediaId" &&
(messageID == "" || messageID == "<nil>" ||
conversationID == "" || conversationID == "<nil>")
if resourceID == "" || resourceID == "<nil>" || missingMediaContext {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 message-id 或 open-conversation-id",
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 resource-id,或 mediaId 缺少 message-id/open-conversation-id",
})
continue
}
data, callErr := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
data, callErr := resolveMessageResourceDownloadData(
rt, resourceType, resourceID, messageID, conversationID)
if callErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
})
continue
}
resourceURL, headers, infoErr := resourceDownloadInfo(data)
if infoErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
})
continue
}
preferredName := resourceDownloadPreferredName(data)
filename := resourceDownloadFilename(resourceURL, preferredName)
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
output := filepath.Join(outputDir, filename)
destPath, relativePath, pathErr := resolveResourceDownloadPath(
cwd, outputDir, resourceURL, rt.Bool("overwrite"))
cwd,
output,
resourceURL,
rt.Bool("overwrite"),
preferredName,
)
if pathErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
})
continue
}
@@ -669,28 +770,47 @@ func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]an
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if downloadErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
})
continue
}
downloadedNames[strings.ToLower(filepath.Base(relativePath))] = true
downloads = append(downloads, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
}
return map[string]any{
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"requestedCount": len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}, nil
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}
}
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
if !used[strings.ToLower(filename)] {
return filename
}
extension := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, extension)
for sequence := 2; ; sequence++ {
candidate := fmt.Sprintf("%s (%d)%s", stem, sequence, extension)
if !used[strings.ToLower(candidate)] {
return candidate
}
}
}
func uniqueShortcutStrings(values []string) []string {
@@ -888,41 +1008,170 @@ var MessagesCreateTextEmotion = shortcut.Shortcut{
},
}
// MessagesSendCard creates and pushes a streaming card (create_and_send_card, im).
// MessagesSendCard creates and optionally completes a streaming card by
// composing create_and_send_card with update_streaming_card.
var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建并推送流式卡片(需配合 messages-update-card)",
Intent: "当你要发送一张可后续流式更新的卡片消息(如 AI 逐字输出)时使用;会实际推送卡片并返回 bizId,群 openConversationId 或单聊接收者 userId 二选一,配合 messages-update-card 更新。",
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 完成,避免卡片停留在加载中。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --receiver 互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group 互斥)"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver", "receiver-open-dingtalk-id"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); status < 1 || status > 5 {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
return nil
},
Tips: []string{`dws chat +messages-send-card --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
group := rt.Str("group")
receiver := rt.Str("receiver")
if group == "" && receiver == "" {
return fmt.Errorf("--group 或 --receiver 必填其一")
}
if group != "" && receiver != "" {
return fmt.Errorf("--group 与 --receiver 互斥")
}
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
if group != "" {
switch {
case group != "":
params["openConversationId"] = group
} else {
params["receiverUid"] = receiver
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
return err
}
params["receiverOpenDingTalkId"] = openID
default:
params["receiverOpenDingTalkId"] = receiverOpenID
}
return rt.CallMCP("create_and_send_card", params)
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
}
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,
"actions": []map[string]any{
{
"tool": "create_and_send_card",
"arguments": params,
},
{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"flowStatus": status,
},
},
},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"flowStatus": status,
})
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
},
}
func findCardBizID(value any) string {
switch typed := value.(type) {
case map[string]any:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
// Prefer documented response envelopes before scanning extension fields.
// Map iteration order is deliberately randomized by Go, so an unordered
// recursive walk could select a stale metadata bizId.
envelopeKeys := []string{"result", "data", "card", "response"}
visited := make(map[string]struct{}, len(directKeys)+len(envelopeKeys))
for _, key := range directKeys {
visited[key] = struct{}{}
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
return candidate
}
}
remainingKeys := make([]string, 0, len(typed))
for key := range typed {
if _, ok := visited[key]; !ok {
remainingKeys = append(remainingKeys, key)
}
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
return candidate
}
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
}
}
}
return ""
}
// MessagesUpdateCard streams updated card content (update_streaming_card, im).
var MessagesUpdateCard = shortcut.Shortcut{
Service: "chat",
@@ -4,10 +4,11 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -25,7 +26,7 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
"content": `{"mediaId":"@image"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted",
"content": `{"mediaId":"@quoted-image"}`,
},
},
}}})
@@ -37,13 +38,23 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
t.Errorf("projection missing %s: %#v", key, rows[0])
}
}
resources := rows[0]["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("projected resources = %#v", resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("quoted resource context = %#v", resources[1])
}
}
func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources"}
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--yes"}
readyMget := `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`
missingContextMget := `{"result":[{"content":"{\"mediaId\":\"@file\"}"}]}`
validInfo := `{"result":{"resourceUrl":"https://example.test/resource.bin"}}`
validInfo := `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
@@ -62,9 +73,53 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
"im/list_messages_by_ids": readyMget,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "工作目录") {
t.Fatalf("getwd error = %v", err)
if err := root.Execute(); err != nil {
t.Fatalf("getwd ledger error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["requestedCount"] != float64(1) ||
ledger["failedCount"] != ledger["requestedCount"] {
t.Fatalf("getwd ledger = %#v", ledger)
}
})
t.Run("zero resources skip getwd", func(t *testing.T) {
resetResourceDownloadHooks(t)
getwdCalled := false
resourceGetwd = func() (string, error) {
getwdCalled = true
return "", errors.New("getwd")
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"plain text"}]}`,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(baseArgs)
if err := root.Execute(); err != nil {
t.Fatalf("zero-resource download error = %v", err)
}
if getwdCalled {
t.Fatal("zero-resource download unnecessarily read the working directory")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
failures, _ := ledger["failures"].([]any)
if ledger["ok"] != true ||
ledger["requestedCount"] != float64(0) ||
ledger["failedCount"] != float64(0) ||
len(failures) != 0 {
t.Fatalf("zero-resource ledger = %#v", ledger)
}
})
@@ -120,3 +175,48 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageMgetDownloadRunsWithoutConfirmation(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetIn(bytes.NewBuffer(nil))
root.SetArgs([]string{
"chat", "+messages-mget",
"--msg-ids", "msg",
"--download-resources",
})
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 != "get_resource_download_url" {
t.Fatalf("download calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["downloadedCount"] != float64(1) || ledger["failedCount"] != float64(0) {
t.Fatalf("download ledger = %#v", ledger)
}
}
@@ -120,17 +120,17 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
},
{
name: "explicit sender lookup",
caller: &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"},
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "解析为 openDingTalkId",
},
{
name: "explicit sender unresolved",
caller: &larkAlignmentCaller{responses: map[string]string{
"contact/get_user_info_by_user_ids": `{"result":[]}`,
"contact/search_contact_by_key_word": `{"result":[]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "无法把 --ref-sender",
wantError: "没有精确匹配",
},
{
name: "referenced message lookup",
@@ -234,7 +234,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg"})
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("mget lower error was swallowed")
}
+887
View File
@@ -0,0 +1,887 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"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) {
want := []string{
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
"uuid", "idempotency-key",
}
var got []string
for _, constraint := range MessagesSend.Constraints {
if constraint.Kind == shortcut.ConstraintCustom {
got = constraint.Flags
break
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("identity constraint flags = %#v, want %#v", got, want)
}
wantSet := make(map[string]bool, len(want))
for _, name := range want {
wantSet[name] = true
}
for _, flag := range MessagesSend.Flags {
if !wantSet[flag.Name] {
continue
}
if !strings.Contains(flag.Desc, "能力矩阵") {
t.Errorf("--%s does not publish identity matrix semantics: %q", flag.Name, flag.Desc)
}
if flag.Name == "user" && !strings.Contains(flag.Desc, "--dry-run") {
t.Errorf("--user does not publish dry-run contact resolution: %q", flag.Desc)
}
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserImageAndUserResolution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--msg-type", "image",
"--media-id", "@image",
"--idempotency-key", "image-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v, want contact resolution + send", fake.calls)
}
if fake.calls[0].product != "contact" || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls[0])
}
if got := fake.calls[0].args["keyword"]; got != "user-id" {
t.Fatalf("resolution keyword = %#v, want user-id", got)
}
send := fake.calls[1]
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("send call = %#v", send)
}
for key, want := range map[string]any{
"receiverOpenDingTalkId": "D-resolved",
"msgType": "image",
"uuid": "image-key",
} {
if !reflect.DeepEqual(send.args[key], want) {
t.Errorf("%s = %#v, want %#v", key, send.args[key], want)
}
}
var content map[string]string
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["mediaId"] != "@image" {
t.Fatalf("image content = %#v", content)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("report.txt", []byte("gap-fill"), 0o600); err != nil {
t.Fatal(err)
}
var uploaded []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
var err error
uploaded, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + server.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":11,"spaceId":22}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--msg-type", "audio",
"--file-path", "./report.txt",
"--uuid", "file-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if string(uploaded) != "gap-fill" || len(fake.calls) != 3 {
t.Fatalf("uploaded = %q, calls = %#v", uploaded, fake.calls)
}
if fake.calls[0].tool != "init_conversation_file_upload" ||
fake.calls[1].tool != "commit_conversation_file_upload" ||
fake.calls[2].tool != "send_personal_message" {
t.Fatalf("file flow calls = %#v", fake.calls)
}
send := fake.calls[2]
if send.args["msgType"] != "file" || send.args["openConversationId"] != "cid" ||
send.args["uuid"] != "file-key" {
t.Fatalf("file send args = %#v", send.args)
}
var content map[string]any
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["dentryId"] != float64(11) || content["spaceId"] != float64(22) ||
content["fileName"] != "report.txt" {
t.Fatalf("file content = %#v", content)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != "audio" || payload["effectiveMessageType"] != "file" {
t.Fatalf("file output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--file", "./fixture.bin",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("file dry-run made lower calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(2) || payload["preview_kind"] != "plan" {
t.Fatalf("dry-run payload = %#v", payload)
}
for _, tc := range []struct {
name string
args []string
}{
{"unsafe path", []string{"--group", "cid", "--file", "../outside"}},
{"missing file", []string{"--group", "cid", "--file", "./missing"}},
{"media mismatch", []string{"--group", "cid", "--media-id", "@image", "--msg-type", "file"}},
{"file mismatch", []string{"--group", "cid", "--file", "./fixture.bin", "--msg-type", "image"}},
{"missing media", []string{"--group", "cid", "--text", "x", "--msg-type", "image"}},
{"bot media", []string{"--identity", "bot", "--robot-code", "robot", "--group", "cid", "--media-id", "@image"}},
{"webhook media", []string{"--identity", "webhook", "--webhook-token", "token", "--media-id", "@image"}},
{"user target conflict", []string{"--group", "cid", "--user", "u1", "--text", "x"}},
{"user media at", []string{"--group", "cid", "--media-id", "@image", "--at-all"}},
{"text flag with markdown type", []string{"--group", "cid", "--text", "x", "--msg-type", "markdown"}},
{"markdown flag with text type", []string{"--group", "cid", "--markdown", "x", "--msg-type", "text"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
command := newPlatformCoverageRoot()
args := append([]string{"chat", "+messages-send"}, tc.args...)
args = append(args, "--yes")
command.SetArgs(args)
if err := command.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", tc.args)
}
})
}
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{
name: "upload init error",
fake: &larkAlignmentCaller{failProductTool: "im/init_conversation_file_upload"},
},
{
name: "commit response missing IDs",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{}`,
}},
},
{
name: "message send error",
fake: &larkAlignmentCaller{
failProductTool: "chat/send_personal_message",
responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{"dentryId":1,"spaceId":2}`,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
command := newPlatformCoverageRoot()
command.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--file", "./fixture.bin",
"--yes",
})
if err := command.Execute(); err == nil {
t.Fatal("file failure scenario unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageMessagesSendUserResolutionFailures(t *testing.T) {
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{"lookup error", &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}},
{"no exact user id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"other-user","openDingTalkId":"D-other"}]}`,
}}},
{"missing open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","name":"Resolved User"}]}`,
}}},
{"ambiguous open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","openDingTalkId":"D-one"},{"userId":"user-id","openDingTalkId":"D-two"}]}`,
}}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--text", "hello",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("unresolved user was accepted")
}
if len(tc.fake.calls) != 1 {
t.Fatalf("calls = %#v", tc.fake.calls)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T) {
for _, args := range [][]string{
{"--group", "cid", "--markdown", "## markdown"},
{"--group", "cid", "--text", "plain", "--msg-type", "text"},
} {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{"chat", "+messages-send"}, args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" {
t.Fatalf("text mode calls = %#v", fake.calls)
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-send-execute-guard",
Flags: MessagesSend.Flags,
Execute: executeMessagesSend,
})
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+gap-send-execute-guard",
"--group", "cid",
"--media-id", "@image",
"--msg-type", "file",
})
if err := root.Execute(); err == nil {
t.Fatal("execute-time content mismatch was accepted")
}
}
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "create_and_send_card" ||
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["bizId"] != "biz-1" || payload["ok"] != true {
t.Fatalf("card output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "d-user-id",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].product != "im" ||
fake.calls[1].tool != "create_and_send_card" {
t.Fatalf("card receiver calls = %#v", fake.calls)
}
if got := fake.calls[1].args["receiverOpenDingTalkId"]; got != "D-resolved" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-resolved", got)
}
if got := fake.calls[0].args["keyword"]; got != "d-user-id" {
t.Fatalf("D/d-prefixed userId resolution args = %#v", fake.calls[0].args)
}
if _, exists := fake.calls[1].args["receiverUid"]; exists {
t.Fatalf("obsolete receiverUid leaked to lower tool: %#v", fake.calls[1].args)
}
}
func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "im" ||
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *testing.T) {
t.Run("create only", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("create-only calls = %#v", fake.calls)
}
})
t.Run("dry run", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user",
"--content", "处理中",
"--flow-status", "1",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("card dry-run receiver resolution calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
arguments, _ := create["arguments"].(map[string]any)
if payload["actionCount"] != float64(2) ||
arguments["receiverOpenDingTalkId"] != "D-resolved" {
t.Fatalf("card plan = %#v", payload)
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user-id",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "解析为 openDingTalkId 失败") {
t.Fatalf("receiver resolution error = %v", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("receiver resolution calls = %#v", fake.calls)
}
})
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
wantError string
}{
{
name: "create error",
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
wantError: "fixture lower call failed",
},
{
name: "missing biz id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"created":true}}`,
}},
wantError: "未返回 bizId",
},
{
name: "update error preserves id",
fake: &larkAlignmentCaller{
failProductTool: "im/update_streaming_card",
responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-preserved"}`,
},
},
wantError: "biz-preserved",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want substring %q", err, tc.wantError)
}
})
}
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+messages-send-card"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid card args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"bizId": "direct"}, "direct"},
{map[string]any{"bizID": "caps"}, "caps"},
{map[string]any{"biz_id": "snake"}, "snake"},
{map[string]any{"result": []any{map[string]any{"bizId": "nested"}}}, "nested"},
{map[string]any{
"metadata": map[string]any{"bizId": "stale"},
"result": map[string]any{"bizId": "current"},
}, "current"},
{map[string]any{
"extension": map[string]any{"bizId": "fallback"},
}, "fallback"},
{map[string]any{
"bizId": map[string]any{"x": 1},
"result": map[string]any{"bizId": "typed"},
}, "typed"},
{map[string]any{"bizId": map[string]any{"x": 1}}, ""},
{`{"result":{"bizId":"json"}}`, "json"},
{`[{"bizId":"array-json"}]`, "array-json"},
{`{"bizId":`, ""},
{"plain", ""},
{42, ""},
} {
if got := findCardBizID(tc.value); got != tc.want {
t.Errorf("findCardBizID(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return 0, err
}
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
message := map[string]any{
"openMessageId": "msg",
"content": `{"mediaId":"@same","nested":{"mediaId":"@same"}}`,
"quotedMessage": map[string]any{
"openMessageId": "msg-quoted",
"content": `{"mediaId":"@quoted"}`,
},
}
secondMessage := map[string]any{
"openMessageId": "msg-2",
"content": `{"mediaId":"@same"}`,
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{message, message, secondMessage}, "cid-fallback")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["discoveredCount"] != 5 || ledger["requestedCount"] != 3 ||
ledger["deduplicatedCount"] != 2 || ledger["downloadedCount"] != 3 {
t.Fatalf("download ledger = %#v", ledger)
}
if len(fake.calls) != 3 {
t.Fatalf("resource lookup = %#v", fake.calls)
}
gotMessageIDs := map[string]bool{}
for _, call := range fake.calls {
if call.args["openConversationId"] != "cid-fallback" {
t.Errorf("resource conversation = %#v", call.args)
}
gotMessageIDs[call.args["openMessageId"].(string)] = true
}
for _, messageID := range []string{"msg", "msg-quoted", "msg-2"} {
if !gotMessageIDs[messageID] {
t.Errorf("missing resource lookup for %q: %#v", messageID, fake.calls)
}
}
}
func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesName(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.WriteFile(dest, []byte("drive-file"), 0o600); err != nil {
return 0, err
}
return int64(len("drive-file")), nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "./downloads/",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join("downloads", "fixture.txt"))
if err != nil || string(content) != "drive-file" {
t.Fatalf("downloaded content = %q, err = %v", content, err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "drive" ||
fake.calls[0].tool != "download_file" ||
fake.calls[0].args["fileId"] != "drive-file" {
t.Fatalf("drive call = %#v", fake.calls)
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-file-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{{
"content": "[文件] fixture.txt fileId: drive-file",
}}, "")
return nil
},
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-file-resource-download", "--output-dir", "./batch-downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 0 {
t.Fatalf("file download ledger = %#v", ledger)
}
downloads := ledger["downloads"].([]map[string]any)
if downloads[0]["resourceType"] != "fileId" ||
downloads[0]["localPath"] != "batch-downloads/fixture.txt" ||
downloads[0]["messageId"] != "" {
t.Fatalf("file download = %#v", downloads[0])
}
}
func TestCrossPlatformCoverageMessageResourceDownloadDisambiguatesSameNames(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-colliding-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{
{"fileId": "file-a"},
{"fileId": "file-b"},
}, "")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture (2).txt"}) {
t.Fatalf("download destinations = %#v", destinations)
}
if ledger["downloadedCount"] != 2 || ledger["failedCount"] != 0 {
t.Fatalf("download ledger = %#v", ledger)
}
}
func TestCrossPlatformCoverageResourceDownloadFilenameCollisionSequence(t *testing.T) {
used := map[string]bool{}
first := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(first)] = true
second := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(second)] = true
third := disambiguateResourceDownloadFilename("a (2).txt", used)
if first != "a.txt" || second != "a (2).txt" || third != "a (2) (2).txt" {
t.Fatalf("collision sequence = %q, %q, %q", first, second, third)
}
}
func TestCrossPlatformCoverageFailedResourceDownloadDoesNotConsumeFilename(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
attempt := 0
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
attempt++
if attempt == 1 {
return 0, errors.New("fixture download failed")
}
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-failed-colliding-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{
{"fileId": "file-a"},
{"fileId": "file-b"},
}, "")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-failed-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture.txt"}) {
t.Fatalf("failed download consumed a filename: %#v", destinations)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 1 {
t.Fatalf("download ledger = %#v", ledger)
}
}
func TestCrossPlatformCoverageMessageResourceDownloadRequiresMediaContextOnly(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--resource-id", "@media",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--type mediaId") {
t.Fatalf("missing media context error = %v", err)
}
root = newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "../outside",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--output") {
t.Fatalf("unsafe output error = %v", err)
}
}
func TestCrossPlatformCoverageReadHelperRejectsWriteToolDirectly(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
if _, err := helpers.CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the helper read channel")
}
if len(fake.calls) != 0 {
t.Fatalf("rejected write reached lower caller: %#v", fake.calls)
}
}
+4 -9
View File
@@ -139,13 +139,13 @@ var MessagesReply = shortcut.Shortcut{
Command: "+messages-reply",
Product: "chat",
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(不传则自动读取)"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(userId 通过通讯录搜索精确匹配;不传则自动读取)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"},
@@ -184,16 +184,11 @@ func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
if isOpenID(value) {
return value, nil
}
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
"user_id_list": []string{value},
})
openID, err := resolveUserOpenDingTalkID(rt, value)
if err != nil {
return "", fmt.Errorf("把 --ref-sender userId 解析为 openDingTalkId 失败: %w", err)
}
if openID := findOpenDingTalkID(data); openID != "" {
return openID, nil
}
return "", apperrors.NewValidation("无法把 --ref-sender 解析为 openDingTalkId")
return openID, nil
}
messageID := replyMessageID(rt)
+42 -6
View File
@@ -44,7 +44,24 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
case "contact/get_current_user_profile":
text = `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"userId":"user-id","openDingTalkId":"D-resolved"}]}`
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-id","orgUserName":"Resolved User"}}]}`
case "contact/search_contact_by_key_word":
keyword, _ := args["keyword"].(string)
payload, _ := json.Marshal(map[string]any{
"result": []map[string]any{
{
"name": "Fuzzy Neighbor",
"userId": keyword + "-other",
"openDingTalkId": "D-other",
},
{
"name": "Resolved User",
"userId": keyword,
"openDingTalkId": "D-resolved",
},
},
})
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/list_messages_by_ids":
@@ -117,27 +134,35 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
product string
tool string
want map[string]any
bodyKey string
body string
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
},
{
name: "bot",
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "<@u1> hello", "--at-user-ids", "u1", "--at-all", "--yes"},
product: "bot",
tool: "send_robot_group_message",
want: map[string]any{"robotCode": "robot", "openConversationId": "cid", "isAtAll": "true"},
bodyKey: "markdown",
body: "@all @u1 hello",
},
{
name: "webhook",
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "<@13800000000> hello", "--at-mobiles", "13800000000", "--at-all", "--yes"},
product: "bot",
tool: "send_message_by_custom_robot",
want: map[string]any{"robotToken": "token", "isAtAll": true},
bodyKey: "text",
body: "@all @13800000000 hello",
},
}
for _, tt := range tests {
@@ -161,6 +186,17 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
t.Errorf("%s = %#v, want %#v", key, call.args[key], want)
}
}
if tt.bodyKey == "content" {
var content map[string]string
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
t.Errorf("content text = %q, want %q", content["text"], tt.body)
}
} else if call.args[tt.bodyKey] != tt.body {
t.Errorf("%s = %#v, want %q", tt.bodyKey, call.args[tt.bodyKey], tt.body)
}
})
}
}
@@ -384,7 +420,7 @@ func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
}
}
func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -401,7 +437,7 @@ func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "get_user_info_by_user_ids" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "send_personal_message" {
t.Fatalf("calls = %#v", fake.calls)
}
+200 -29
View File
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -54,16 +55,16 @@ var MessagesResourceDownload = shortcut.Shortcut{
Service: "chat",
Command: "+messages-resource-download",
Product: "im",
Description: "安全下载消息资源(图片/视频/语音)到本地",
Intent: "当你需要拿到消息里的实际图片、视频或语音文件,而不只是临时 URL 时使用;" +
"先用消息和会话身份换取下载地址,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖。",
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
},
@@ -73,17 +74,34 @@ var MessagesResourceDownload = shortcut.Shortcut{
Flags: []string{"output"},
Description: "--output 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"type", "message-id", "open-conversation-id"},
Description: "--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文",
},
},
Tips: []string{
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`,
`dws chat +messages-resource-download --type fileId --resource-id <fileId> --output ./downloads/`,
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
return validateResourceDownloadOutput(rt.Str("output"))
if err := validateResourceDownloadOutput(rt.Str("output")); err != nil {
return err
}
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
if resourceType == "mediaId" &&
(strings.TrimSpace(rt.Str("message-id")) == "" ||
strings.TrimSpace(rt.Str("open-conversation-id")) == "") {
return apperrors.NewValidation(
"--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
plan := map[string]any{
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"resourceId": rt.Str("resource-id"),
"messageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
@@ -101,12 +119,13 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(plan)
}
data, err := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": rt.Str("type"),
"resourceId": rt.Str("resource-id"),
"openMessageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
})
data, err := resolveMessageResourceDownloadData(
rt,
resourceType,
rt.Str("resource-id"),
rt.Str("message-id"),
rt.Str("open-conversation-id"),
)
if err != nil {
return err
}
@@ -119,7 +138,12 @@ var MessagesResourceDownload = shortcut.Shortcut{
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
destPath, relativePath, err := resolveResourceDownloadPath(
cwd, rt.Str("output"), resourceURL, rt.Bool("overwrite"))
cwd,
rt.Str("output"),
resourceURL,
rt.Bool("overwrite"),
resourceDownloadPreferredName(data),
)
if err != nil {
return err
}
@@ -131,13 +155,46 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(map[string]any{
"messageId": rt.Str("message-id"),
"resourceId": rt.Str("resource-id"),
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
},
}
func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) {
resourceType, ok := canonicalMessageResourceType(resourceType)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf(
"不支持的消息资源类型 %q;仅支持 mediaId 或 fileId", resourceType))
}
if resourceType == "fileId" {
return rt.CallMCPData("drive", "download_file", map[string]any{
"fileId": resourceID,
})
}
return rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
}
func canonicalMessageResourceType(resourceType string) (string, bool) {
switch {
case strings.EqualFold(strings.TrimSpace(resourceType), "mediaId"):
return "mediaId", true
case strings.EqualFold(strings.TrimSpace(resourceType), "fileId"):
return "fileId", true
default:
return strings.TrimSpace(resourceType), false
}
}
func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output")
}
@@ -195,10 +252,12 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
parsed.Scheme = "https"
resourceURL = parsed.String()
}
if parsed.Scheme != "https" {
return "", nil, apperrors.NewAPI(
"资源下载接口未返回合法的 HTTPS 下载地址")
parsed, err = validateResourceDownloadURL(resourceURL)
if err != nil {
return "", nil, apperrors.NewAPI(fmt.Sprintf(
"资源下载接口返回了不受信任的下载地址: %v", err))
}
resourceURL = parsed.String()
headers := map[string]string{}
if values, ok := data["headers"].(map[string]any); ok {
@@ -211,14 +270,65 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
return resourceURL, headers, nil
}
func resourceDownloadPreferredName(data map[string]any) string {
for {
result, ok := data["result"].(map[string]any)
if !ok {
break
}
data = result
}
name, _ := data["fileName"].(string)
return strings.TrimSpace(name)
}
func isAliyunOSSHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "aliyuncs.com" || strings.HasSuffix(host, ".aliyuncs.com")
if !strings.HasSuffix(host, ".aliyuncs.com") {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(host, ".aliyuncs.com"), ".") {
if (label == "oss" || strings.HasPrefix(label, "oss-")) &&
!strings.Contains(label, "internal") {
return true
}
}
return false
}
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil ||
parsed.Scheme != "https" ||
strings.TrimSpace(parsed.Host) == "" ||
parsed.User != nil {
return nil, apperrors.NewValidation("资源下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), "."))
if host == "" || net.ParseIP(host) != nil || !isResourceDownloadAllowedHost(host) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"资源下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host))
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, apperrors.NewValidation("资源下载地址只允许使用 HTTPS 默认端口")
}
return parsed, nil
}
func isResourceDownloadAllowedHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
// Lower tools may return signed headers with the URL. Keep those headers
// confined to platform-owned public download families; extend this list
// only after observing another official production download host.
return isAliyunOSSHost(host) ||
host == "dingtalk.com" ||
strings.HasSuffix(host, ".dingtalk.com")
}
func resolveResourceDownloadPath(
baseDir, output, resourceURL string,
overwrite bool,
preferredName ...string,
) (absolutePath, relativePath string, err error) {
if err := validateResourceDownloadOutput(output); err != nil {
return "", "", err
@@ -242,7 +352,7 @@ func resolveResourceDownloadPath(
directoryIntent ||
output == "."
if isDirectory {
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL))
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL, preferredName...))
}
parent := filepath.Dir(candidate)
@@ -321,13 +431,17 @@ func ensureResourceDownloadParent(baseDir, parent string) error {
return nil
}
func resourceDownloadFilename(resourceURL string) string {
func resourceDownloadFilename(resourceURL string, preferredName ...string) string {
if len(preferredName) > 0 {
if name := safeResourceDownloadFilename(preferredName[0]); name != "" {
return name
}
}
parsed, err := url.Parse(resourceURL)
if err == nil {
name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path))
if unescapeErr == nil {
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
if name != "" && name != "." && name != string(os.PathSeparator) {
if name = safeResourceDownloadFilename(name); name != "" {
return name
}
}
@@ -335,6 +449,43 @@ func resourceDownloadFilename(resourceURL string) string {
return "download"
}
// safeResourceDownloadFilename returns a portable basename or an empty string
// for names that are unsafe or unusable on a supported platform. Server-provided
// file names are untrusted input, and downloads may be prepared on one OS then
// consumed on another.
func safeResourceDownloadFilename(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 := name
if index := strings.IndexByte(stem, '.'); index >= 0 {
stem = stem[:index]
}
stem = strings.ToUpper(strings.TrimRight(stem, " ."))
switch stem {
case "CON", "PRN", "AUX", "NUL":
return ""
}
if len(stem) == 4 &&
(stem[:3] == "COM" || stem[:3] == "LPT") &&
stem[3] >= '1' && stem[3] <= '9' {
return ""
}
return name
}
func downloadResourceAtomically(
ctx context.Context,
client *http.Client,
@@ -346,22 +497,42 @@ func downloadResourceAtomically(
if client == nil {
client = &http.Client{Timeout: resourceDownloadTimeout}
}
parsedResourceURL, err := validateResourceDownloadURL(resourceURL)
if err != nil {
return 0, err
}
clientCopy := *client
client = &clientCopy
originalRedirect := client.CheckRedirect
initialHost := strings.ToLower(parsedResourceURL.Hostname())
headersConfinedToInitialHost := true
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || strings.TrimSpace(req.URL.Host) == "" {
return apperrors.NewValidation("资源下载重定向必须使用 HTTPS")
if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"资源下载重定向指向了不受信任的地址: %v", redirectErr))
}
if len(via) >= 10 {
return apperrors.NewAPI("资源下载重定向次数过多")
}
if !strings.EqualFold(req.URL.Hostname(), initialHost) {
headersConfinedToInitialHost = false
}
// net/http rebuilds redirect headers from the initial request on every
// hop. Once a chain leaves the original host, strip lower-service
// headers on every later hop so a same-host redirect on the new origin
// cannot silently restore them.
if !headersConfinedToInitialHost {
for key := range headers {
req.Header.Del(key)
}
}
if originalRedirect != nil {
return originalRedirect(req, via)
}
return nil
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
request, err := http.NewRequestWithContext(
ctx, http.MethodGet, parsedResourceURL.String(), nil)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("创建资源下载请求失败: %v", err))
}
@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -58,6 +57,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
"--resource-id", "@image",
"--message-id", "msg",
"--open-conversation-id", "cid",
"--yes",
}
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
@@ -89,7 +89,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceAbs = func(string) (string, error) { return "", errors.New("path") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -132,7 +132,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceDownload = tc.download
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--output", filepath.Join(t.TempDir(), "file")))
@@ -164,7 +164,7 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
}
}
resourceURL, headers, err := resourceDownloadInfo(map[string]any{
"resourceUrl": "https://example.test/file",
"resourceUrl": "https://download.dingtalk.com/file",
"headers": map[string]any{
"": "ignored",
"X-Count": 3,
@@ -175,14 +175,29 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
t.Fatalf("resource info = %q %#v %v", resourceURL, headers, err)
}
for host, want := range map[string]bool{
"ALIYUNCS.COM.": true,
"bucket.aliyuncs.com": true,
"example.com": false,
"ALIYUNCS.COM.": false,
"bucket.aliyuncs.com": false,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"bucket.oss-internal.aliyuncs.com": false,
"bucket.oss-cn-hangzhou-internal.aliyuncs.com": false,
"example.com": false,
} {
if got := isAliyunOSSHost(host); got != want {
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
}
}
for host, want := range map[string]bool{
"DINGTALK.COM.": true,
"download.dingtalk.com": true,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"aliyuncs.com.evil.test": false,
"evildingtalk.com": false,
"download.example.invalid": false,
} {
if got := isResourceDownloadAllowedHost(host); got != want {
t.Errorf("isResourceDownloadAllowedHost(%q) = %v, want %v", host, got, want)
}
}
}
func TestCrossPlatformCoverageResourceDownloadPathErrors(t *testing.T) {
@@ -348,6 +363,24 @@ func TestCrossPlatformCoverageResourceDownloadFilenameFallbacks(t *testing.T) {
if got := resourceDownloadFilename("https://example.test/a%20b.txt"); got != "a b.txt" {
t.Fatalf("decoded filename = %q", got)
}
for _, unsafeName := range []string{
"..",
"CON",
"nul.txt",
"COM1.log",
"trailing.",
"trailing ",
"line\nbreak.txt",
`bad:name.txt`,
} {
got := resourceDownloadFilename(
"https://download.dingtalk.com/fallback.bin",
unsafeName,
)
if got != "fallback.bin" {
t.Errorf("unsafe preferred name %q produced %q", unsafeName, got)
}
}
}
type resourceRoundTripper func(*http.Request) (*http.Response, error)
@@ -375,63 +408,105 @@ func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
errorClient := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport")
})}
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("transport error was swallowed")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("HTTP 500 was accepted")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("content-length mismatch was accepted")
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Test") != "ok" {
t.Errorf("missing forwarded header")
}
_, _ = w.Write([]byte("body"))
}))
t.Cleanup(plain.Close)
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
if _, err := downloadResourceAtomically(context.Background(), nil, plain.URL, map[string]string{"X-Test": "ok"}, nilClientDest, true); err != nil {
t.Fatal(err)
if _, err := downloadResourceAtomically(
context.Background(), nil, "https://evil.example/file",
map[string]string{"X-Test": "ok"}, nilClientDest, true,
); err == nil {
t.Fatal("nil-client path accepted an untrusted URL")
}
}
func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) {
httpRedirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, "http://example.test/file", http.StatusFound)
}))
t.Cleanup(httpRedirect.Close)
httpRedirect := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"http://evil.example/file"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), httpRedirect.Client(), httpRedirect.URL, nil,
context.Background(), httpRedirect, "https://download.dingtalk.com/start", nil,
filepath.Join(t.TempDir(), "http-redirect"), false,
); err == nil {
t.Fatal("HTTP redirect was accepted")
}
var loop *httptest.Server
loop = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, loop.URL, http.StatusFound)
}))
t.Cleanup(loop.Close)
loop := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://download.dingtalk.com/loop"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), loop.Client(), loop.URL, nil,
context.Background(), loop, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "loop"), false,
); err == nil {
t.Fatal("redirect loop was accepted")
}
original := loop.Client()
original := *loop
original.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("original redirect policy")
}
if _, err := downloadResourceAtomically(
context.Background(), original, loop.URL, nil,
context.Background(), &original, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "original"), false,
); err == nil {
t.Fatal("original redirect rejection was swallowed")
}
redirectCount := 0
crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
redirectCount++
switch redirectCount {
case 1:
if request.Header.Get("X-Resource-Token") != "secret" {
t.Fatal("initial signed header missing")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/intermediate"}},
}, nil
case 2:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header leaked on first cross-host hop")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/final"}},
}, nil
default:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header was restored on later same-host hop")
}
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
ContentLength: 2,
Header: make(http.Header),
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), crossHost, "https://download.dingtalk.com/start",
map[string]string{"X-Resource-Token": "secret"},
filepath.Join(t.TempDir(), "cross-host"), false,
); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
@@ -439,7 +514,7 @@ func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
run := func(t *testing.T, overwrite bool) error {
t.Helper()
_, err := downloadResourceAtomically(
context.Background(), client, "https://example.test/file", nil,
context.Background(), client, "https://download.dingtalk.com/file", nil,
filepath.Join(t.TempDir(), "resource"), overwrite)
return err
}
@@ -516,7 +591,7 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
dest := filepath.Join(t.TempDir(), "resource")
if _, err := downloadResourceAtomically(
context.Background(), resourceResponseClient(200, "ok", 2),
"https://example.test/file", nil, dest, true,
"https://download.dingtalk.com/file", nil, dest, true,
); err != nil {
t.Fatal(err)
}
@@ -15,18 +15,41 @@ package chat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResourceDownloadInfo(t *testing.T) {
func TestCrossPlatformCoverageCanonicalMessageResourceType(t *testing.T) {
for input, want := range map[string]string{
"mediaId": "mediaId",
"MEDIAID": "mediaId",
" fileid ": "fileId",
"FileId": "fileId",
} {
got, ok := canonicalMessageResourceType(input)
if !ok || got != want {
t.Errorf("canonicalMessageResourceType(%q) = %q, %v; want %q, true",
input, got, ok, want)
}
}
if got, ok := canonicalMessageResourceType("attachment"); ok || got != "attachment" {
t.Errorf("unsupported resource type = %q, %v", got, ok)
}
if _, err := resolveMessageResourceDownloadData(
nil, "attachment", "resource", "message", "conversation",
); err == nil || !strings.Contains(err.Error(), "不支持的消息资源类型") {
t.Fatalf("unsupported resolver type error = %v", err)
}
}
func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
url, headers, err := resourceDownloadInfo(map[string]any{
"result": map[string]any{
"resourceUrl": []any{"https://download.example.test/path/image.png"},
"resourceUrl": []any{"https://download.dingtalk.com/path/image.png"},
"headers": map[string]any{
"X-Test": "value",
},
@@ -35,7 +58,7 @@ func TestResourceDownloadInfo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if url != "https://download.example.test/path/image.png" {
if url != "https://download.dingtalk.com/path/image.png" {
t.Fatalf("url = %q", url)
}
if headers["X-Test"] != "value" {
@@ -57,6 +80,19 @@ func TestResourceDownloadInfo(t *testing.T) {
}); err == nil {
t.Fatal("plain HTTP URL unexpectedly accepted")
}
for _, resourceURL := range []string{
"https://evil.example/file",
"https://aliyuncs.com.evil.example/file",
"https://127.0.0.1/file",
"https://user:secret@download.dingtalk.com/file",
"https://download.dingtalk.com:8443/file",
} {
if _, _, err := resourceDownloadInfo(
map[string]any{"resourceUrl": resourceURL},
); err == nil {
t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL)
}
}
}
func TestResolveResourceDownloadPath(t *testing.T) {
@@ -115,16 +151,23 @@ func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t
}
}
func TestDownloadResourceAtomically(t *testing.T) {
func TestCrossPlatformCoverageDownloadResourceAtomically(t *testing.T) {
const body = "verified download bytes"
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
client := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
if request.URL.String() != "https://download.dingtalk.com/resource.bin" {
t.Fatalf("request URL = %q", request.URL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
dest := filepath.Join(t.TempDir(), "resource.bin")
size, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false)
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false)
if err != nil {
t.Fatal(err)
}
@@ -139,7 +182,7 @@ func TestDownloadResourceAtomically(t *testing.T) {
t.Fatalf("body = %q", got)
}
if _, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false,
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false,
); err == nil || !strings.Contains(err.Error(), "已存在") {
t.Fatalf("no-clobber error = %v", err)
}
+296 -29
View File
@@ -5,41 +5,51 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
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"
)
// MessagesSend is the identity-aware common text/Markdown entry point. Media
// upload remains on the native message send command until DWS has one shared
// upload contract; this Shortcut does not pretend webhook/bot/current-user
// transports support identical capabilities.
const messagesSendFileUploadTimeout = 10 * time.Minute
// 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
// because their lower transports do not expose equivalent media contracts.
var MessagesSend = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Product: "chat",
Description: "统一发送文本/Markdown(current user、bot、webhook)",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送文本/Markdown 时使用;命令会按身份校验目标和凭据并路由到真实下层。current-user 支持幂等键,bot 支持群聊或批量单聊,webhook 的目标由 token 所在群决定。媒体上传仍使用原生命令。",
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份"},
{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: "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)"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(bot 必填)"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(webhook 必填)"},
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束"},
{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: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"},
{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);受发送身份能力矩阵约束"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"},
{Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user)"},
{Name: "msg-type", Type: shortcut.FlagString, Enum: []string{"text", "markdown", "image", "file", "audio", "video"}, Desc: "内容类型;省略时根据正文、--media-id 或 --file 自动推断"},
{Name: "media-id", Type: shortcut.FlagString, Desc: "已有图片 mediaId(仅 user 的 image)"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"},
{Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user);受发送身份能力矩阵约束"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"},
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"},
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(webhook)"},
@@ -47,19 +57,21 @@ var MessagesSend = shortcut.Shortcut{
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"identity", "as"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"user", "open-dingtalk-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"identity", "as", "group", "chat-id", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "user 必须指定 group/open-dingtalk-id 之一;bot 必须指定 robot-code 和群聊/批量单聊目标之一;webhook 必须指定 webhook-token;幂等键仅 user 支持",
Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
},
},
Tips: []string{
`dws chat +messages-send --as user --chat-id <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
},
Validate: validateMessagesSend,
@@ -69,16 +81,28 @@ var MessagesSend = shortcut.Shortcut{
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
group := rt.StrFirst("chat-id", "group")
userID := rt.Str("user")
openID := rt.Str("open-dingtalk-id")
users := uniqueShortcutStrings(rt.StrSlice("users"))
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
if contentType == "text" && rt.Str("markdown") != "" {
return apperrors.NewValidation("--msg-type text 必须与 --text 一起使用")
}
if contentType == "markdown" && rt.Str("text") != "" {
return apperrors.NewValidation("--msg-type markdown 必须与 --markdown 一起使用")
}
switch identity {
case "user":
if (group == "") == (openID == "") {
return apperrors.NewValidation("--identity user 时 --group 与 --open-dingtalk-id 必须且只能指定一个")
targetCount := nonEmptyStringCount(group, userID, openID)
if targetCount != 1 {
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
}
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
@@ -86,9 +110,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
}
if openID != "" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
}
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
case "bot":
if rt.Str("robot-code") == "" {
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
@@ -97,8 +124,8 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if (group != "") == hasDirect {
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
}
if openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --open-dingtalk-id 或 --webhook-token")
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
}
if len(atMobiles) > 0 {
return apperrors.NewValidation("--identity bot 不接受 --at-mobiles")
@@ -109,11 +136,14 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
}
case "webhook":
if rt.Str("webhook-token") == "" {
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
}
if group != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
}
if len(atOpenIDs) > 0 {
@@ -122,6 +152,9 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
}
}
return nil
}
@@ -135,12 +168,43 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
switch identity {
case "user":
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
group, openID, err := messagesSendUserTarget(rt)
if err != nil {
return err
}
if contentType == "image" {
content, _ := json.Marshal(map[string]string{"mediaId": rt.Str("media-id")})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "image",
"content": string(content),
})
addMessagesSendUserTarget(params, group, openID)
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
}
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 := rt.StrFirst("chat-id", "group"); group != "" {
if group != "" {
params["openConversationId"] = group
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
params["atOpenDingTalkIds"] = values
@@ -149,13 +213,22 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
params["atAll"] = true
}
} else {
params["receiverOpenDingTalkId"] = rt.Str("open-dingtalk-id")
params["receiverOpenDingTalkId"] = openID
}
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
case "bot":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": title,
@@ -185,6 +258,15 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
return executeUnifiedMessageWrite(rt, "bot", "batch_send_robot_msg_to_users", params)
case "webhook":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-mobiles"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotToken": rt.Str("webhook-token"),
"title": title,
@@ -241,6 +323,191 @@ func messagesSendBody(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("markdown", "text")
}
func messagesSendContentType(rt *shortcut.RuntimeContext) (string, error) {
contentType := strings.ToLower(rt.Str("msg-type"))
switch {
case rt.Str("media-id") != "":
if contentType != "" && contentType != "image" {
return "", apperrors.NewValidation("--media-id 只支持 --msg-type image")
}
return "image", nil
case rt.StrFirst("file", "file-path") != "":
if contentType == "" {
return "file", nil
}
if contentType != "file" && contentType != "audio" && contentType != "video" {
return "", apperrors.NewValidation("--file 只支持 --msg-type file/audio/video")
}
return contentType, nil
default:
if contentType == "" {
if rt.Str("markdown") != "" {
return "markdown", nil
}
return "text", nil
}
if contentType != "text" && contentType != "markdown" {
return "", apperrors.NewValidation(fmt.Sprintf("--msg-type %s 需要匹配的 --media-id 或 --file", contentType))
}
return contentType, nil
}
}
func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, err error) {
group = rt.StrFirst("chat-id", "group")
openID = rt.Str("open-dingtalk-id")
if openID != "" || group != "" {
return group, openID, nil
}
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
if err != nil {
return "", "", err
}
return "", openID, nil
}
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{
"keyword": userID,
})
if err != nil {
return "", fmt.Errorf("通过通讯录搜索把 userId %q 解析为 openDingTalkId 失败: %w", userID, err)
}
exactRows := 0
openIDs := make([]string, 0, 1)
for _, user := range shortcutMapSlice(data["result"]) {
if shortcutString(user, "userId", "userID") != userID {
continue
}
exactRows++
if openID := shortcutString(user, "openDingTalkId", "openDingtalkId"); openID != "" {
openIDs = appendUniqueShortcutString(openIDs, openID)
}
}
switch {
case len(openIDs) == 1:
return openIDs[0], nil
case len(openIDs) > 1:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录中 userId %q 精确匹配到多个不同的 openDingTalkId,无法安全选择",
userID,
))
case exactRows > 0:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录已精确匹配 userId %q,但结果未返回 openDingTalkId",
userID,
))
default:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录搜索结果中没有精确匹配的 userId %q",
userID,
))
}
}
func executeMessagesSendUserFile(
rt *shortcut.RuntimeContext,
group, openID, requestedType string,
) error {
rawPath := rt.StrFirst("file", "file-path")
safePath, err := apperrors.SafeInputPath(rawPath)
if err != nil {
return err
}
meta, err := helpers.BuildConversationLocalFileMeta(safePath, "", "")
if err != nil {
return err
}
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
},
{
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": targetArgs,
},
},
})
}
uploadContext, cancelUpload := context.WithTimeout(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, targetArgs, meta, idempotencyKey)
if err != nil {
return err
}
dentryID, spaceID, err := helpers.ParseConversationFileSendIDs(commitText)
if err != nil {
return err
}
content, _ := helpers.BuildConversationFileContent(dentryID, spaceID, meta)
params := rt.AddAIMessageTag(map[string]any{
"msgType": "file",
"content": content,
})
addMessagesSendUserTarget(params, group, openID)
if idempotencyKey != "" {
params["uuid"] = idempotencyKey
}
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
if err != nil {
return err
}
return rt.Output(map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["receiverOpenDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("idempotency-key", "uuid")
}
+156 -26
View File
@@ -207,21 +207,25 @@ func firstMessageValue(m map[string]any, keys ...string) any {
return nil
}
// Resources extracts actionable media references from both structured message
// fields and the textual mediaId notation returned by older DingTalk message
// APIs. Every reference publishes the exact Shortcut arguments already known
// from the message, plus ready=false and missing fields when a follow-up lookup
// is still required. This shared shape is used by list, search, mget, quoted
// messages and thread replies.
// Resources extracts actionable media and drive-file references from both
// structured message fields and the textual mediaId/fileId notation returned
// by older DingTalk message APIs. Every reference publishes the exact Shortcut
// arguments already known from the message, plus ready=false and missing fields
// when a follow-up lookup is still required. This shared shape is used by list,
// search, mget, quoted messages and thread replies.
func Resources(m map[string]any) []map[string]any {
if m == nil {
return nil
}
ids := make([]string, 0)
collectMediaIDs(m, &ids)
ids = uniqueMediaIDs(ids)
sort.Strings(ids)
if len(ids) == 0 {
mediaIDs := make([]string, 0)
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -234,8 +238,8 @@ func Resources(m map[string]any) []map[string]any {
conversationID = ""
}
out := make([]map[string]any, 0, len(ids))
for _, id := range ids {
out := make([]map[string]any, 0, len(mediaIDs)+len(fileIDs))
for _, id := range mediaIDs {
arguments := map[string]any{
"type": "mediaId",
"resource-id": id,
@@ -262,36 +266,94 @@ func Resources(m map[string]any) []map[string]any {
},
})
}
for _, id := range fileIDs {
out = append(out, map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
"shortcut": "+messages-resource-download",
"arguments": map[string]any{
"type": "fileId",
"resource-id": id,
},
"ready": true,
"missing": []string{},
},
})
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)media[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
// ResourcesDeep returns resources from a message and each nested quoted,
// replied-to or forwarded message. Every nested resource is projected from the
// child message that owns it, so its download arguments never reuse the parent
// message ID. A missing child conversation ID inherits the enclosing
// conversation because quoted and forwarded records often omit that duplicate
// field.
func ResourcesDeep(m map[string]any) []map[string]any {
return resourcesDeep(m, "", 0)
}
func collectMediaIDs(value any, out *[]string) {
const maxResourceMessageDepth = 32
func resourcesDeep(m map[string]any, inheritedConversationID string, depth int) []map[string]any {
if m == nil || depth > maxResourceMessageDepth {
return nil
}
conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m)))
if conversationID == "" || conversationID == "<nil>" {
conversationID = inheritedConversationID
}
owned := m
if ConversationID(m) == nil && conversationID != "" {
owned = make(map[string]any, len(m)+1)
for key, value := range m {
owned[key] = value
}
owned["openConversationId"] = conversationID
}
out := append([]map[string]any(nil), Resources(owned)...)
if depth == maxResourceMessageDepth {
return out
}
for _, child := range nestedMessageChildren(m) {
out = append(out, resourcesDeep(child, conversationID, depth+1)...)
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
case map[string]any:
resourceType := strings.TrimSpace(fmt.Sprint(firstMessageValue(typed, "resourceType", "resource_type")))
for key, child := range typed {
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if normalizedKey == "mediaid" || (normalizedKey == "resourceid" && strings.EqualFold(resourceType, "mediaId")) {
if id := mediaIDScalar(child); id != "" {
normalizedKey := normalizeMessageKey(key)
if normalizedKey == targetKey ||
(normalizedKey == "resourceid" && strings.EqualFold(resourceType, targetKey)) {
if id := resourceIDScalar(child); id != "" {
*out = append(*out, id)
}
}
collectMediaIDs(child, out)
if isNestedMessageBoundaryKey(normalizedKey) {
continue
}
collectResourceIDs(child, targetKey, textPattern, out)
}
case []any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case []map[string]any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case string:
for _, match := range mediaIDTextRE.FindAllStringSubmatch(typed, -1) {
for _, match := range textPattern.FindAllStringSubmatch(typed, -1) {
if len(match) > 1 {
if id := mediaIDScalar(match[1]); id != "" {
if id := resourceIDScalar(match[1]); id != "" {
*out = append(*out, id)
}
}
@@ -300,13 +362,81 @@ func collectMediaIDs(value any, out *[]string) {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectMediaIDs(decoded, out)
collectResourceIDs(decoded, targetKey, textPattern, out)
}
}
}
}
func mediaIDScalar(value any) string {
func normalizeMessageKey(key string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
}
func isNestedMessageBoundaryKey(key string) bool {
switch key {
case "quotedmessage", "replymessage", "quoted", "replytomessage",
"forwardmessages", "forwardedmessages", "forwarded":
return true
default:
return false
}
}
func nestedMessageMaps(value any) []map[string]any {
switch typed := value.(type) {
case map[string]any:
return []map[string]any{typed}
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if child, ok := item.(map[string]any); ok {
out = append(out, child)
}
}
return out
case string:
var decoded any
if json.Unmarshal([]byte(strings.TrimSpace(typed)), &decoded) == nil {
return nestedMessageMaps(decoded)
}
}
return nil
}
func nestedMessageChildren(value any) []map[string]any {
out := make([]map[string]any, 0)
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
out = append(out, nestedMessageMaps(child)...)
continue
}
out = append(out, nestedMessageChildren(child)...)
}
case []any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case []map[string]any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
out = append(out, nestedMessageChildren(decoded)...)
}
}
}
return out
}
func resourceIDScalar(value any) string {
text, ok := value.(string)
if !ok {
return ""
@@ -314,7 +444,7 @@ func mediaIDScalar(value any) string {
return strings.Trim(strings.TrimSpace(text), `"'`)
}
func uniqueMediaIDs(values []string) []string {
func uniqueResourceIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
+116
View File
@@ -189,6 +189,111 @@ func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) {
}
}
func TestCrossPlatformCoverageResourcesRespectNestedMessageOwnership(t *testing.T) {
message := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid-parent",
"content": map[string]any{"mediaId": "media-parent"},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": map[string]any{"mediaId": "media-quoted"},
},
"forwardMessages": []any{
map[string]any{
"openMessageId": "forwarded",
"openConversationId": "cid-forwarded",
"content": map[string]any{"mediaId": "media-forwarded"},
},
},
}
owned := Resources(message)
if len(owned) != 1 || owned[0]["resourceId"] != "media-parent" {
t.Fatalf("parent resources crossed message boundary: %#v", owned)
}
deep := ResourcesDeep(message)
if len(deep) != 3 {
t.Fatalf("deep resources = %#v", deep)
}
argumentsByResource := map[string]map[string]any{}
for _, resource := range deep {
download := resource["download"].(map[string]any)
argumentsByResource[resource["resourceId"].(string)] = download["arguments"].(map[string]any)
}
for resourceID, want := range map[string][2]string{
"media-parent": {"parent", "cid-parent"},
"media-quoted": {"quoted", "cid-parent"},
"media-forwarded": {"forwarded", "cid-forwarded"},
} {
arguments := argumentsByResource[resourceID]
if arguments["message-id"] != want[0] ||
arguments["open-conversation-id"] != want[1] {
t.Errorf("%s arguments = %#v, want message=%q conversation=%q",
resourceID, arguments, want[0], want[1])
}
}
}
func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
encoded := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid",
"content": `{"quotedMessage":{"openMessageId":"encoded-child","mediaId":"nested"}}`,
}
if got := Resources(encoded); got != nil {
t.Fatalf("encoded nested resource bound to parent: %#v", got)
}
if got := ResourcesDeep(encoded); len(got) != 1 {
t.Fatalf("encoded nested resources = %#v", got)
} else {
arguments := got[0]["download"].(map[string]any)["arguments"].(map[string]any)
if arguments["message-id"] != "encoded-child" ||
arguments["open-conversation-id"] != "cid" {
t.Fatalf("encoded child arguments = %#v", arguments)
}
}
if got := resourcesDeep(map[string]any{"mediaId": "x"}, "", maxResourceMessageDepth+1); got != nil {
t.Fatalf("over-depth resources = %#v", got)
}
if got := resourcesDeep(
map[string]any{"mediaId": "x"},
"",
maxResourceMessageDepth,
); len(got) != 1 {
t.Fatalf("max-depth owned resources = %#v", got)
}
if got := nestedMessageMaps([]map[string]any{{"id": "a"}}); len(got) != 1 {
t.Fatalf("map slice = %#v", got)
}
if got := nestedMessageMaps(`[{"id":"a"}]`); len(got) != 1 {
t.Fatalf("encoded message list = %#v", got)
}
if got := nestedMessageMaps([]any{"bad", map[string]any{"id": "a"}}); len(got) != 1 {
t.Fatalf("mixed message list = %#v", got)
}
if got := nestedMessageMaps("{"); got != nil {
t.Fatalf("invalid encoded messages = %#v", got)
}
if got := nestedMessageChildren([]map[string]any{
{"content": "plain"},
{"content": `{"forwardedMessages":[{"openMessageId":"m"}]}`},
}); len(got) != 1 {
t.Fatalf("nested message children = %#v", got)
}
if got := nestedMessageChildren([]any{
"plain",
map[string]any{"quoted": map[string]any{"openMessageId": "m"}},
}); len(got) != 1 {
t.Fatalf("mixed nested message children = %#v", got)
}
if got := nestedMessageChildren("{"); len(got) != 0 {
t.Fatalf("invalid nested children = %#v", got)
}
if isNestedMessageBoundaryKey("content") {
t.Fatal("ordinary content treated as a message boundary")
}
}
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
if got := UpdateTime(map[string]any{
"createTime": "2026-07-19 13:37:03",
@@ -326,6 +431,17 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) {
}
}
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
resources := Resources(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"content": `notmediaId=@false mediaId=@real`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@real" {
t.Fatalf("resources = %#v, want only the bounded mediaId", resources)
}
}
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
@@ -29,14 +29,27 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"mediaId": 42},
{"resourceType": "fileId", "resourceId": "drive-file"},
{"mediaId": 42, "fileId": 42},
},
"content": `[文件] report.txt fileId: drive-file`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@file-a" {
if len(resources) != 2 ||
resources[0]["resourceId"] != "@file-a" ||
resources[1]["resourceId"] != "drive-file" ||
resources[1]["type"] != "fileId" {
t.Fatalf("resources = %#v", resources)
}
if got := mediaIDScalar(42); got != "" {
t.Fatalf("non-string media ID = %q", got)
fileDownload := resources[1]["download"].(map[string]any)
fileArguments := fileDownload["arguments"].(map[string]any)
if fileDownload["ready"] != true ||
fileDownload["shortcut"] != "+messages-resource-download" ||
fileArguments["type"] != "fileId" ||
fileArguments["resource-id"] != "drive-file" {
t.Fatalf("file download = %#v", fileDownload)
}
if got := resourceIDScalar(42); got != "" {
t.Fatalf("non-string resource ID = %q", got)
}
}
+4 -11
View File
@@ -172,15 +172,7 @@ func dryRunWriteError(product, tool string) error {
}
func looksReadTool(tool string) bool {
tool = strings.TrimSpace(strings.ToLower(tool))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(tool, prefix) {
return true
}
}
return false
return helpers.IsReadToolName(tool)
}
func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]any) (map[string]any, error) {
@@ -469,10 +461,11 @@ func validateConstraints(rt *RuntimeContext, s Shortcut) error {
// --dry-run is set. Read-only shortcuts never prompt. Returns false when the
// user declines.
func confirmRisk(rt *RuntimeContext, s Shortcut) bool {
if s.risk() == RiskRead || rt.DryRun() || rt.Yes() {
risk := s.risk()
if risk == RiskRead || rt.DryRun() || rt.Yes() {
return true
}
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk())
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, risk)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
+9 -9
View File
@@ -435,7 +435,7 @@
},
"+messages-send": {
"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 能力。",
"risk": "write",
"public": true,
"reviewed": true
@@ -487,7 +487,7 @@
},
"+messages-mget": {
"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 契约,不产生非交互确认盲区。",
"risk": "read",
"public": true,
"reviewed": true
@@ -542,8 +542,8 @@
"reviewed": true
},
"+messages-send-card": {
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"risk": "write",
"public": true,
"reviewed": true
@@ -564,7 +564,7 @@
},
"+messages-resource-download": {
"disposition": "primary_smart",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"risk": "read",
"public": true,
"reviewed": true
@@ -627,7 +627,7 @@
},
"+at-me": {
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -641,7 +641,7 @@
},
"+chat-messages": {
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -677,7 +677,7 @@
},
"+search-msg": {
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"risk": "read",
"public": true,
"reviewed": true
@@ -691,7 +691,7 @@
},
"+thread-replies": {
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
+37
View File
@@ -15,6 +15,7 @@ package shortcut
import (
"bytes"
"os"
"strings"
"testing"
@@ -40,6 +41,42 @@ func TestCrossPlatformCoverageRiskDefaultsToRead(t *testing.T) {
}
}
func TestCrossPlatformCoverageConfirmRiskPromptsForStaticWrite(t *testing.T) {
cmd := &cobra.Command{}
var stderr bytes.Buffer
cmd.SetErr(&stderr)
rt := &RuntimeContext{cmd: cmd}
s := Shortcut{
Service: "chat",
Command: "+messages-send",
Risk: RiskWrite,
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previousStdin := os.Stdin
os.Stdin = reader
t.Cleanup(func() {
os.Stdin = previousStdin
_ = reader.Close()
_ = writer.Close()
})
if _, err := writer.WriteString("yes\n"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if !confirmRisk(rt, s) {
t.Fatal("static write risk was not confirmed")
}
if got := stderr.String(); !strings.Contains(got, "chat +messages-send(write)") {
t.Fatalf("confirmation prompt = %q", got)
}
}
func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
s := Shortcut{
Service: "contact",
+13 -7
View File
@@ -19,6 +19,7 @@ import (
"time"
"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"
)
@@ -37,9 +38,9 @@ import (
// so it honours --format/--jq/--fields. When the response carries no
// recognisable message list we fall back to printing the raw payload.
//
// This replaces manually working out the millisecond time window and copying the
// list-mentions incantation. Read-only: it only searches and reshapes, never
// sends, recalls or marks anything.
// This replaces manually working out the millisecond time window and copying
// the list-mentions incantation. The default path only searches and reshapes;
// --download-resources additionally writes resource files locally.
//
// dws chat +at-me
// dws chat +at-me --days 3
@@ -51,18 +52,20 @@ var AtMe = shortcut.Shortcut{
Intent: "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;" +
"内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息," +
"再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" +
"这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
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: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// 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.
@@ -99,6 +102,9 @@ var AtMe = shortcut.Shortcut{
}
payload := map[string]any{"messages": results}
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
}
return rt.Output(payload)
},
}
@@ -233,7 +239,7 @@ func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[strin
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
+29 -11
View File
@@ -15,11 +15,19 @@ package smart
import (
"strings"
"time"
"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"
)
var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60)
func formatDingTalkMessageBoundary(now time.Time) string {
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
}
// ChatMessages: fetch the message list of one conversation (group OR single
// chat) and print a clean projected list (speaker / text / time) instead of a
// raw dump.
@@ -35,8 +43,8 @@ import (
// aliases and one level of nesting;
// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields.
//
// Read-only: it only reads a conversation's messages and reshapes them locally,
// never posts or mutates anything.
// The default path only reads and reshapes conversation messages;
// --download-resources additionally writes resource files locally.
//
// dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"
// dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50
@@ -47,39 +55,43 @@ var ChatMessages = shortcut.Shortcut{
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" +
"群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" +
"可选 --time 指定起始时间、--limit 指定每页条数、--direction newer/older 控制时间方向(newer 从给定时间往现在拉,older 往以前拉)。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"},
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — build params and pick the right tool. Param keys
// (openconversation_id / userId / time / forward / limit) match the MCP server
// schema for group and direct message listing.
var tool string
params := map[string]any{}
fallbackConversationID := ""
if rt.Changed("time") && rt.Str("time") != "" {
params["time"] = rt.Str("time")
} else {
params["time"] = formatDingTalkMessageBoundary(time.Now())
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
@@ -94,11 +106,14 @@ var ChatMessages = shortcut.Shortcut{
case "older":
params["forward"] = false
}
} else {
params["forward"] = false
}
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
tool = "list_conversation_message_v2"
params["openconversation_id"] = group
fallbackConversationID = group
} else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" {
tool = "list_individual_chat_message"
params["openDingTalkId"] = openID
@@ -126,6 +141,9 @@ var ChatMessages = shortcut.Shortcut{
}
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
chatmsg.ApplyMessagePagination(payload, data, items, direction)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, fallbackConversationID)
}
return rt.Output(payload)
},
}
@@ -198,7 +216,7 @@ func projectChatMessageWithReactions(m map[string]any, includeReactions bool) ma
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -67,7 +67,7 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
"emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted body",
"content": `{"mediaId":"@quoted-image"}`,
},
}
for name, row := range map[string]map[string]any{
@@ -80,6 +80,16 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
t.Errorf("%s projection missing %s: %#v", name, key, row)
}
}
resources := row["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("%s projected resources = %#v", name, resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("%s quoted resource context = %#v", name, resources[1])
}
}
}
@@ -89,7 +99,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -202,7 +212,7 @@ func TestCrossPlatformCoverageSearchValidationAndTimeErrors(t *testing.T) {
for _, tail := range cases {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+search-msg"}, tail...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid search args succeeded: %v", tail)
}
@@ -247,7 +257,7 @@ func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) {
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, tc.args...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{AtMe, ChatMessages, SearchMsg, ThreadReplies} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}`
tests := []struct {
name string
tool string
response string
args []string
resultKey string
}{
{
name: "chat messages",
tool: "chat/list_conversation_message_v2",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+chat-messages", "--group", "cid"},
resultKey: "messages",
},
{
name: "search",
tool: "im/search_messages",
response: `{"result":{"messages":[` + message + `],"hasMore":false}}`,
args: []string{"chat", "+search-msg", "--query", "x", "--no-enrich"},
resultKey: "messages",
},
{
name: "at me",
tool: "chat/search_at_me_message",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid","messages":[` + message + `]}]}}`,
args: []string{"chat", "+at-me"},
resultKey: "messages",
},
{
name: "thread replies",
tool: "chat/list_topic_replies",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
resultKey: "replies",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
tc.tool: {tc.response},
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{}, tc.args...)
args = append(args, "--download-resources", "--output-dir", "./downloads", "--dry-run")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.counts[tc.tool] != 1 {
t.Fatalf("lower calls = %#v", caller.counts)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
rows, ok := payload[tc.resultKey].([]any)
if !ok || len(rows) != 1 {
t.Fatalf("payload missing %s: %#v", tc.resultKey, payload)
}
row, _ := rows[0].(map[string]any)
resources, _ := row["resourceRefs"].([]any)
if len(resources) != 2 {
t.Fatalf("visible resources = %#v", resources)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["dryRun"] != true ||
ledger["discoveredCount"] != float64(2) ||
ledger["requestedCount"] != float64(2) {
t.Fatalf("resource plan = %#v", ledger)
}
})
}
}
func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-messages", "--group", "cid"},
{"chat", "+search-msg", "--query", "x", "--no-enrich"},
{"chat", "+at-me"},
{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
} {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append(args, "--download-resources", "--output-dir", "../outside", "--yes"))
if err := root.Execute(); err == nil {
t.Fatalf("unsafe output accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
before := time.Now().Add(-2 * time.Second)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
after := time.Now().Add(2 * time.Second)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["forward"] != false {
t.Fatalf("default forward = %#v, want false", call.args["forward"])
}
boundary, err := time.ParseInLocation(
"2006-01-02 15:04:05",
call.args["time"].(string),
dingTalkMessageLocation,
)
if err != nil {
t.Fatal(err)
}
if boundary.Before(before) || boundary.After(after) {
t.Fatalf("default time = %s, want current boundary", boundary)
}
}
func TestFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC)
if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" {
t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got)
}
}
func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-messages",
"--group", "cid",
"--time", "2026-07-01 12:34:56",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 ||
caller.calls[0].args["time"] != "2026-07-01 12:34:56" {
t.Fatalf("explicit time call = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T) {
original := map[string]any{"openMessageId": "m1"}
items := searchMsgItems(map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
"invalid-group",
map[string]any{"messages": "invalid"},
map[string]any{
"openConversationId": "cid",
"title": "会话",
"singleChat": true,
"messages": []any{
"invalid-message",
original,
map[string]any{
"openMessageId": "m2",
"openConversationId": "own-cid",
"conversationTitle": "own-title",
"singleChat": false,
},
},
},
},
},
})
if len(items) != 2 {
t.Fatalf("grouped items = %#v", items)
}
if items[0]["openConversationId"] != "cid" || items[0]["conversationTitle"] != "会话" ||
items[0]["singleChat"] != true {
t.Fatalf("injected group context = %#v", items[0])
}
if items[1]["openConversationId"] != "own-cid" || items[1]["conversationTitle"] != "own-title" ||
items[1]["singleChat"] != false {
t.Fatalf("message context was overwritten: %#v", items[1])
}
if _, mutated := original["openConversationId"]; mutated {
t.Fatalf("source message mutated: %#v", original)
}
if searchMsgChildMap(map[string]any{"result": "invalid"}, "result") != nil {
t.Fatal("non-map child was accepted")
}
}
+83 -11
View File
@@ -21,6 +21,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -35,9 +36,10 @@ var SearchMsg = shortcut.Shortcut{
Product: "im",
Description: "多维搜索消息,可全量翻页并批量富化详情",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。",
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
@@ -65,17 +67,22 @@ var SearchMsg = shortcut.Shortcut{
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"query", "group", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Kind: shortcut.ConstraintAtLeastOne,
Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Description: "至少指定一个内容、身份、会话或消息类型过滤条件",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start", "end", "days"},
Description: "--start 与 --end 必须同时指定;否则使用 --days 时间窗",
Flags: []string{"start"},
Description: "需与 --end 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
@@ -83,12 +90,12 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
},
Validate: validateSearchMsg,
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
if err != nil {
@@ -200,10 +207,20 @@ var SearchMsg = shortcut.Shortcut{
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
}
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "")
}
return rt.Output(payload)
},
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
}
return chatshortcut.ValidateMessageResourceDownload(rt)
}
func validateSearchMsg(rt *shortcut.RuntimeContext) error {
hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
len(rt.StrSlice("groups")) > 0 ||
@@ -392,6 +409,14 @@ func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
@@ -408,6 +433,53 @@ func searchMsgItems(data map[string]any) []map[string]any {
return nil
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
@@ -453,7 +525,7 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -73,7 +73,7 @@ func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...st
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, args...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, args...))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
+12 -7
View File
@@ -15,6 +15,7 @@ package smart
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"
)
@@ -29,8 +30,8 @@ import (
// project each reply to {sender, text, createTime} tolerating field aliases;
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
//
// Read-only: it only reads a topic's replies and reshapes them locally, never
// posts or mutates anything.
// The default path only reads and reshapes topic replies;
// --download-resources additionally writes resource files locally.
//
// dws chat +thread-replies --group <openconversationId> --thread-id <threadId>
var ThreadReplies = shortcut.Shortcut{
@@ -41,27 +42,28 @@ var ThreadReplies = shortcut.Shortcut{
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintExactlyOne,
Flags: []string{"thread-id", "topic-id"},
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — fetch the topic replies. Param keys (openconversationId /
// topicId / startTime / pageSize) are copied verbatim from chat.go's
@@ -96,6 +98,9 @@ var ThreadReplies = shortcut.Shortcut{
"count": len(results),
}
chatmsg.ApplyMessagePagination(payload, data, items, "older")
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, rt.Str("group"))
}
return rt.Output(payload)
},
}
+76
View File
@@ -0,0 +1,76 @@
// 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 agentproduct defines the caller-provided Agent product identity
// carried on outbound DWS requests.
package agentproduct
import (
"errors"
"os"
"regexp"
"strings"
)
const (
// EnvName is the runtime override for the Agent product identity.
EnvName = "DWS_AGENT_PRODUCT"
// HeaderName is the existing wire header used for the Agent product.
HeaderName = "claw-type"
// MaxValueBytes bounds the value because it is attached to every outbound
// MCP request. Supported values are ASCII, so bytes and characters match.
MaxValueBytes = 64
)
// ErrInvalid is returned when an Agent product value is unsafe or does not
// match the supported wire format. The error intentionally excludes the raw
// caller-controlled value.
var ErrInvalid = errors.New("invalid agent product")
var valuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// Parse normalizes and validates a caller-provided Agent product. Only
// surrounding ASCII spaces and tabs are trimmed; other control or Unicode
// whitespace remains visible to validation and is rejected. An unset or
// ASCII-whitespace-only value means "use the edition default".
func Parse(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", ErrInvalid
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > MaxValueBytes {
return "", ErrInvalid
}
if !valuePattern.MatchString(value) {
return "", ErrInvalid
}
return value, nil
}
// ResolveFromEnv returns the validated DWS_AGENT_PRODUCT value, or fallback
// when the environment variable is unset or empty. Invalid caller input is
// returned as an error so command entrypoints can fail before network access.
func ResolveFromEnv(fallback string) (string, error) {
value, err := Parse(os.Getenv(EnvName))
if err != nil {
return "", err
}
if value == "" {
return fallback, nil
}
return value, nil
}
+105
View File
@@ -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 agentproduct
import (
"errors"
"strings"
"testing"
)
func TestParse(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "qwenwork", raw: "qwenwork", want: "qwenwork"},
{name: "legacy open claw", raw: "openClaw", want: "openClaw"},
{name: "trim", raw: " \tqwenwork\t ", want: "qwenwork"},
{name: "generic", raw: "agent-2_alpha", want: "agent-2_alpha"},
{name: "leading digit", raw: "2nd_product", want: "2nd_product"},
{name: "maximum length", raw: strings.Repeat("a", MaxValueBytes), want: strings.Repeat("a", MaxValueBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got != tc.want {
t.Fatalf("Parse() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork\r"},
{name: "line feed", raw: "\nqwenwork"},
{name: "internal space", raw: "qwen work"},
{name: "internal tab", raw: "qwen\twork"},
{name: "unicode", raw: "千问办公"},
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vqwenwork"},
{name: "form feed", raw: "qwenwork\f"},
{name: "next line", raw: "qwenwork\u0085"},
{name: "non-breaking space", raw: "\u00a0qwenwork"},
{name: "ideographic space", raw: "qwenwork\u3000"},
{name: "too long", raw: strings.Repeat("a", MaxValueBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.raw)
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse(%q) = %q, %v; want ErrInvalid", tc.raw, got, err)
}
if strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
func TestResolveFromEnv(t *testing.T) {
t.Run("unset uses fallback", func(t *testing.T) {
t.Setenv(EnvName, "")
got, err := ResolveFromEnv("openClaw")
if err != nil || got != "openClaw" {
t.Fatalf("ResolveFromEnv() = %q, %v; want openClaw, nil", got, err)
}
})
t.Run("valid environment wins", func(t *testing.T) {
t.Setenv(EnvName, " qwenwork ")
got, err := ResolveFromEnv("openClaw")
if err != nil || got != "qwenwork" {
t.Fatalf("ResolveFromEnv() = %q, %v; want qwenwork, nil", got, err)
}
})
t.Run("invalid environment does not return fallback", func(t *testing.T) {
t.Setenv(EnvName, "qwen work")
got, err := ResolveFromEnv("openClaw")
if got != "" || !errors.Is(err, ErrInvalid) {
t.Fatalf("ResolveFromEnv() = %q, %v; want empty, ErrInvalid", got, err)
}
})
}
+8 -10
View File
@@ -15,20 +15,18 @@ package edition
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
// DefaultOSSClawType is the wire value for request header claw-type in
// the open-source build. It is intentionally hard-wired — the open-source
// CLI does NOT derive claw-type from DINGTALK_AGENT or any other caller
// input, so third-party hosts get a predictable header regardless of
// their environment.
// DefaultOSSClawType is the default wire value for request header claw-type
// in the open-source build. DWS_AGENT_PRODUCT may explicitly override the
// request identity; unrelated caller inputs such as DINGTALK_AGENT do not.
const DefaultOSSClawType = "openClaw"
// defaultHooks returns the open-source edition defaults.
//
// MergeHeaders is the only hook that ships with behaviour: it pins the
// `claw-type` request header to DefaultOSSClawType so every open-source
// MCP request carries the same stable routing tag. All other fields are
// nil — the internal code interprets nil as "use standard open-source
// behaviour".
// MergeHeaders is the only hook that ships with behaviour: it supplies
// DefaultOSSClawType so every open-source MCP request has a stable default.
// The core applies an optional DWS_AGENT_PRODUCT override after edition hooks.
// All other fields are nil — the internal code interprets nil as "use
// standard open-source behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
+16 -7
View File
@@ -85,10 +85,12 @@ type Hooks struct {
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// ClawTypeValue is the claw identity carried in message-send tool
// ClawTypeValue is the display identity carried only in message-send tool
// arguments (parameter clawType) so the IM server can render the
// "Send from AI" indicator on delivered messages. Empty → falls back
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
// "Send from AI" indicator on delivered messages. It is intentionally
// separate from the HTTP claw-type Agent Product header and is not
// overridden by DWS_AGENT_PRODUCT. Empty → DefaultOSSClawType; overlays
// set their own message-display value (e.g. "wukong").
ClawTypeValue string
// PersonalEventSourceID identifies the personal-event source channel
@@ -106,9 +108,15 @@ type Hooks struct {
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
// MergeHeaders must preserve base headers. If it sets claw-type, that value
// must be deterministic and independent of the supplied base map because
// PAT error serialization resolves it with an empty map. The hook must not
// perform network, keychain, credential-refresh, or other blocking work.
MergeHeaders func(base map[string]string) map[string]string
// --- EnterpriseCredential HTTP headers ---
// This hook is only for credential material. It must not set claw-type;
// the core reasserts that Header after the hook returns.
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth ---
@@ -198,10 +206,11 @@ func Override(h *Hooks) {
current = h
}
// ClawType returns the claw identity for the active edition, falling back
// to DefaultOSSClawType when the overlay does not set one. Message-send
// helpers attach this value as the clawType tool argument so the IM server
// can label delivered messages as sent via AI.
// ClawType returns the message-display identity for the active edition,
// falling back to DefaultOSSClawType when the overlay does not set one.
// Message-send helpers attach this value as the clawType tool argument so the
// IM server can label delivered messages as sent via AI. It is a separate axis
// from the HTTP claw-type header and is not affected by DWS_AGENT_PRODUCT.
func ClawType() string {
if v := Get().ClawTypeValue; v != "" {
return v
+8 -6
View File
@@ -25,19 +25,19 @@ MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md"
SERVICE_TO_SKILL = {
"aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md",
"attendance": ROOT / "skills" / "multi" / "dingtalk-attendance" / "SKILL.md",
"attendance": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "attendance.md",
"calendar": ROOT / "skills" / "multi" / "dingtalk-calendar" / "SKILL.md",
"chat": ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
"contact": ROOT / "skills" / "multi" / "dingtalk-contact" / "SKILL.md",
"devapp": ROOT / "skills" / "multi" / "dingtalk-dev" / "SKILL.md",
"ding": ROOT / "skills" / "multi" / "dingtalk-ding" / "SKILL.md",
"ding": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "ding.md",
"doc": ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
"drive": ROOT / "skills" / "multi" / "dingtalk-drive" / "SKILL.md",
"mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md",
"minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md",
"oa": ROOT / "skills" / "multi" / "dingtalk-oa" / "SKILL.md",
"report": ROOT / "skills" / "multi" / "dingtalk-report" / "SKILL.md",
"sheet": ROOT / "skills" / "multi" / "dingtalk-sheet" / "SKILL.md",
"oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md",
"report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md",
"sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md",
"todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md",
"wiki": ROOT / "skills" / "multi" / "dingtalk-wiki" / "SKILL.md",
}
@@ -88,7 +88,9 @@ def mono_overview(items: list[dict[str, Any]]) -> str:
rows = []
for service, count in sorted(counts.items()):
path = SERVICE_TO_SKILL.get(service)
skill = path.parent.name if path else "—"
skill = "—"
if path:
skill = next((part for part in path.parts if part.startswith("dingtalk-")), path.parent.name)
rows.append(f"| `{md_escape(service)}` | {count} | `{md_escape(skill)}` | `dws shortcut list --service {md_escape(service)} --format json` |")
body = "\n".join(rows)
return f"""{MONO_START}
+16 -16
View File
@@ -43,22 +43,22 @@ cli_version: ">=1.0.15"
| 服务 | shortcut 数 | multi skill | 发现命令 |
|---|---:|---|---|
| `aitable` | 29 | `dingtalk-aitable` | `dws shortcut list --service aitable --format json` |
| `attendance` | 19 | `dingtalk-attendance` | `dws shortcut list --service attendance --format json` |
| `calendar` | 20 | `dingtalk-calendar` | `dws shortcut list --service calendar --format json` |
| `chat` | 42 | `dingtalk-chat` | `dws shortcut list --service chat --format json` |
| `contact` | 14 | `dingtalk-contact` | `dws shortcut list --service contact --format json` |
| `devapp` | 19 | `dingtalk-dev` | `dws shortcut list --service devapp --format json` |
| `ding` | 4 | `dingtalk-ding` | `dws shortcut list --service ding --format json` |
| `doc` | 17 | `dingtalk-doc` | `dws shortcut list --service doc --format json` |
| `drive` | 7 | `dingtalk-drive` | `dws shortcut list --service drive --format json` |
| `mail` | 10 | `dingtalk-mail` | `dws shortcut list --service mail --format json` |
| `minutes` | 6 | `dingtalk-minutes` | `dws shortcut list --service minutes --format json` |
| `oa` | 7 | `dingtalk-oa` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-report` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-sheet` | `dws shortcut list --service sheet --format json` |
| `todo` | 11 | `dingtalk-todo` | `dws shortcut list --service todo --format json` |
| `wiki` | 1 | `dingtalk-wiki` | `dws shortcut list --service wiki --format json` |
| `aitable` | 29 | `dingtalk-workspace` | `dws shortcut list --service aitable --format json` |
| `attendance` | 19 | `dingtalk-workspace` | `dws shortcut list --service attendance --format json` |
| `calendar` | 20 | `dingtalk-workspace` | `dws shortcut list --service calendar --format json` |
| `chat` | 97 | `dingtalk-workspace` | `dws shortcut list --service chat --format json` |
| `contact` | 14 | `dingtalk-workspace` | `dws shortcut list --service contact --format json` |
| `devapp` | 19 | `dingtalk-workspace` | `dws shortcut list --service devapp --format json` |
| `ding` | 4 | `dingtalk-workspace` | `dws shortcut list --service ding --format json` |
| `doc` | 17 | `dingtalk-workspace` | `dws shortcut list --service doc --format json` |
| `drive` | 7 | `dingtalk-workspace` | `dws shortcut list --service drive --format json` |
| `mail` | 10 | `dingtalk-workspace` | `dws shortcut list --service mail --format json` |
| `minutes` | 6 | `dingtalk-workspace` | `dws shortcut list --service minutes --format json` |
| `oa` | 7 | `dingtalk-workspace` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-workspace` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-workspace` | `dws shortcut list --service sheet --format json` |
| `todo` | 11 | `dingtalk-workspace` | `dws shortcut list --service todo --format json` |
| `wiki` | 1 | `dingtalk-workspace` | `dws shortcut list --service wiki --format json` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
## 多组织 / 多账号

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