Compare commits

...
9 Commits
Author SHA1 Message Date
修雨 754b0df056 docs(changelog): add 1.0.30 release notes (#322)
* docs(changelog): add 1.0.30 release notes

Notes for the wukong-IM-envelope alignment + schema-pipeline / transform /
market enhancements that landed via #317. Also extends the README "Pipe &
File Input" section with the `@<text>` ASCII-prefix rule that lets literal
Chinese mentions like `@所有人` / `@张三` pass through unchanged.

Validated against `dws-wukong/auto-test/cli_to_mcp` with `--edition open`,
account wukong01, on this main:
- chat: 107 passed / 0 failed / 0 errored → 100.0% PASS
- report: auto-test/cli_to_mcp/open_report/test_report_20260519_155449.md

* docs(changelog,readme): drop internal validation note; refresh chat row

- CHANGELOG: remove the validation paragraph from the 1.0.30 entry — it
  cited an internal repo path, a test account, and a local-only report
  file, none of which belong in public release notes.
- README / README_zh: bump the Chat / IM row in the Key Services table
  from 23 → 57 leaves and expand the subcommand list to match the
  current chat tree (group-mute / group-mute-member / mute / set-top /
  list-categories / list-conversations, plus message reply, search /
  search-advanced, forward, emoji & text-emotion reactions, cards,
  group member-role CRUD, transfer-owner, set-admin, quit, ...). Count
  matches `dws chat --help` enumeration on this main.
2026-05-19 17:29:35 +08:00
修雨 6be124777f feat: align CLI to wukong IM envelope + schema pipeline / transform / market enhancements (#317)
把 test/pre-mcp-discovery 上累积的稳定改动整理成一个 commit 合入 main,剔除
test-only 的预发环境硬编码与 merge/revert 噪音。

- internal/helpers: 重写 chat.go 对齐 wukong IM envelope,拆分共享的 chat
  命令,删减历史测试桩
- internal/compat: 新增 Pipeline tool override + executor、CLIAliases
  override、json_parse_strict 与 file_read transform;envelope leaf cmd
  Args 从 NoArgs 放宽到 ArbitraryArgs
- internal/cli: stdin 处理增强,覆盖更多 --content / --content-file 路径
- internal/app: direct_runtime / runner 适配嫁接硬编码 helper 到动态命令树
- internal/market: registry 强化 + 单元测试

最终 diff: 12 个文件,+369 / -387。
2026-05-19 15:25:39 +08:00
修雨 355a1460d9 docs(changelog): add 1.0.29 release notes (#309)
CHANGELOG: write up 1.0.29 — summary paragraph, then Added (3 new
envelope products: aiapp / live / aisearch with their flag aliases /
subcommand aliases / short flags rationale), Fixed (#306 leaf cmd
ArbitraryArgs), and the existing Security entry (#300 app.json
edition partitioning) preserved.

README.md / README_zh.md: lift aiapp / aisearch / live out of "Coming
soon" into the Key Services table; rename "Coming soon" to keep only
conference; bump totals 16 → 19 products / 204 → 209 commands.
2026-05-17 17:59:28 +08:00
c6edc84e40 fix(auth): partition app.json filename by edition to isolate credentials (#300)
* fix(auth): partition app.json filename by edition to isolate credentials

Two dws binaries sharing the same config directory (typically ~/.dws or
DWS_CONFIG_DIR) previously read and wrote a single app.json. Editions
that pin AuthClientID via hooks still go through the open-core
post-login persistence path, which records a bare ClientID without a
paired ClientSecret. The sibling edition reading the same path would
then adopt that foreign clientID via ResolveAppCredentials.

Mirror the strategy already used by the cache loader
(pkg/config.EditionPartition): GetAppConfigPath returns a filename
suffixed with the active edition name. Open-source keeps "app.json" for
backwards compatibility; sibling editions land on "app-<edition>.json".
LoadAppConfig / SaveAppConfig / HasAppConfig / DeleteAppConfig all
route through GetAppConfigPath, so this single change physically
isolates credential files end-to-end without any read-time heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): address app config partition review

* fix(auth): clean legacy sibling app config

* test(auth): cover legacy app config cleanup guards

* fix(auth): close app config review follow-ups

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:43:20 +08:00
修雨 f497047fff fix(compat): relax envelope leaf cmd Args from NoArgs to ArbitraryArgs (#306)
NewDirectCommand was hard-coding cobra.NoArgs for envelope-generated
leaf commands that have no positional bindings (totalMax == 0). This
is stricter than cobra's own default — legacyArgs (args.go:30-32)
returns nil for any command without subcommands.

The strict behavior surfaced as "unknown command \"<word>\" for
\"dws aisearch person\"" whenever an AI agent passed trailing
positional words after a leaf, e.g.

  dws aisearch person search --keyword "张"
  dws aisearch person user search --keyword "张"

Switching to cobra.ArbitraryArgs restores cobra's natural leaf
behavior: trailing positional args are silently ignored. Existing
positional-binding paths (MinimumNArgs / RangeArgs / MaximumNArgs)
are unchanged.

Verified against dws-wukong/auto-test/cli_to_mcp/testcases on
aiapp / live / aisearch: 50/50 pass (was 48/50; the 2 remaining
failures were the F-class extra-positional-args tolerance cases
this commit fixes).
2026-05-17 17:23:17 +08:00
修雨 a9de7d3ca4 chore(readme): refresh community DingTalk group QR (#296)
* chore(readme): refresh community DingTalk group QR

Replace the external alicdn-hosted QR image with a repo-tracked one
(.github/assets/community-qr.png), so the README is self-contained and
not dependent on third-party CDN availability.

QR encodes an external (cross-org) DingTalk group "dws开源沟通群",
valid until 2027-05-14. Scan with DingTalk mobile to join.

* chore(readme): use alicdn-hosted QR image (no repo binary)

Drop the repo-tracked .github/assets/community-qr.png and reference the
new community group QR via the alicdn CDN URL instead, matching the
original README convention (external image, no binary asset in tree).

QR points to "dws 开源沟通群" (external/cross-org DingTalk group),
valid until 2027-05-14. Mobile scan to join.

* chore(readme): match prior QR image width (150px)
2026-05-15 13:56:54 +08:00
FuShu-Yang 1c200d883f fix(app): prevent command field from overwriting existing endpoint (#297)
* fix(app): prevent command field from overwriting existing endpoint

In AppendDynamicServer, when a plugin declares command != id, the command
endpoint is written unconditionally, overwriting any previously registered entry.

Fix: use first-writer-wins - only write if the key is not yet present.

id registration and dynamicProducts remain unconditional (unaffected).

* test(app): add regression test for command endpoint first-writer-wins guard
2026-05-15 11:29:07 +08:00
修雨 d268524084 docs(changelog): add 1.0.28 release notes (#295) 2026-05-14 21:34:28 +08:00
修雨 ed4673e7d2 fix(chat): require --title for group messages, completing #250's symmetric fix (#294)
`send_message_as_user`'s schema also marks `title` as required, but
`buildChatMessageSendInvocation` only pre-validated it for direct
messages. Sending `dws chat message send --group <cid> --text "1"`
without `--title` therefore reached the API and returned the same
misleading `发群服务窗会话消息失败` business error that #250 fixed for
direct messages, just on the other branch.

Pre-validation now covers both branches: group sends without title
return `--title is required for group messages (--group)`, direct sends
keep the existing `--title is required for direct messages (...)`
message. `Long` help, the `--title` flag description, the first
`Example` line, and `skills/references/products/chat.md` (including the
deeper "drive → chat" workflow example) are realigned to
"群聊与单聊都必填". `internal/helpers/chat_test.go` gains a
`group-without-title` case, and the existing `group` / `positional-text`
success cases are updated to pass `--title`. No API request shape change.
2026-05-14 21:13:28 +08:00
23 changed files with 906 additions and 433 deletions
+50
View File
@@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.30] - 2026-05-19
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
### Added
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
### Changed
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
### Fixed
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
## [1.0.29] - 2026-05-17
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
### Added
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
### Fixed
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
### Security
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
## [1.0.28] - 2026-05-14
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
### Fixed
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
## [1.0.27] - 2026-05-14
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
+9 -4
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "Weekly Report" --text @-
```
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
</details>
## Key Services
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
@@ -416,16 +418,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **204 commands across 16 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **209 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
<details>
<summary>Coming soon</summary>
`conference` (video) · `aiapp` (AI apps) · `live` (streaming)
`conference` (video meetings)
</details>
+9 -4
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
<details>
<summary><strong>目录</strong></summary>
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "周报" --text @-
```
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
</details>
## 核心服务
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
@@ -416,16 +418,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **16 个产品,204 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **19 个产品,209 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
<details>
<summary>即将推出</summary>
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)
`conference`(视频会议)
</details>
+22 -1
View File
@@ -210,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
}
}
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
// whose toolOverrides registered this tool name. Used to correct catalog
// lookups when two envelope servers share the same cli.command and the
// per-product endpoint map collides (see runner.go cross-check).
func directRuntimeToolEndpoint(toolName string) (string, bool) {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return "", false
}
dynamicMu.RLock()
te := dynamicToolEndpoints
dynamicMu.RUnlock()
if te == nil {
return "", false
}
endpoint, ok := te[toolName]
return endpoint, ok && strings.TrimSpace(endpoint) != ""
}
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
normalized := normalizeDirectRuntimeProductID(productID)
@@ -310,7 +329,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
}
cmd := strings.TrimSpace(server.CLI.Command)
if cmd != "" && cmd != id && endpoint != "" {
dynamicEndpoints[cmd] = endpoint
if _, exists := dynamicEndpoints[cmd]; !exists {
dynamicEndpoints[cmd] = endpoint
}
dynamicProducts[cmd] = true
}
for _, alias := range server.CLI.Aliases {
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
}
}
// --- Command field first-writer-wins regression test ---
//
// When two plugins declare the same CLI.Command but different CLI.ID values,
// AppendDynamicServer must NOT let the second registration overwrite the
// command → endpoint mapping established by the first. The fix uses a simple
// "if not exists" guard on dynamicEndpoints[cmd].
const (
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
)
func firstPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testFirstEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-alpha",
Command: "shared-cmd",
},
}
}
func secondPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testSecondEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-beta",
Command: "shared-cmd",
},
}
}
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
// two plugins declare the same Command (but different IDs), only the first
// registration takes effect for the command → endpoint mapping. The second
// plugin's own id-based endpoint is unaffected.
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
withCleanDynamicRegistry(t)
AppendDynamicServer(firstPluginDescriptor())
AppendDynamicServer(secondPluginDescriptor())
// The command "shared-cmd" must resolve to the first plugin's endpoint.
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
// Each plugin's own id-based endpoint is always unconditionally written.
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
// Command must appear in dynamicProducts (discovery) regardless.
ids := DirectRuntimeProductIDs()
if !ids["shared-cmd"] {
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-alpha"] {
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-beta"] {
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
}
}
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
// tool-level routing still works as a fallback when productID is empty or has
// no registered endpoint (the original design intent for tool-level Priority 1).
+5 -5
View File
@@ -624,7 +624,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
if !retryHasKey {
t.Fatal("expected retry context to have patRetryingKey")
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
}
// Verify SetClientIDFromMCP was called with the PAT response clientId.
@@ -700,7 +700,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
@@ -759,7 +759,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
patOut, ok := err.(*apperrors.PATError)
@@ -855,7 +855,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
@@ -918,7 +918,7 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
if opened != rawURI {
+13
View File
@@ -219,6 +219,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
endpoint = override
}
// Multi-server tool-name authority correction.
//
// When two envelope servers share the same cli.command (e.g. group-chat
// and im both publish `dws chat ...`), the endpoints[cmd] map in
// registerDynamicServer is the second-writer wins, and catalog FindProduct
// may pick the wrong product's Endpoint for a tool whose real owner is
// a different server. Cross-check the canonical tool→endpoint map: when
// the per-tool endpoint exists and differs from the per-product endpoint
// catalog returned, trust the tool-owner endpoint (the server that
// actually declares this tool in its toolOverrides).
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
endpoint = toolEndpoint
}
return r.executeInvocation(ctx, endpoint, invocation)
}
+49 -5
View File
@@ -16,21 +16,31 @@ package auth
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
// appConfigFile is the filename for storing app credentials.
appConfigFile = "app.json"
// appConfigFile is the filename for the open-source edition's app
// credentials store. Sibling editions get a name-suffixed file via
// config.EditionFileName so two dws binaries sharing the same config
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
// credentials. See GetAppConfigPath for the path derivation contract.
appConfigBase = "app"
appConfigExt = ".json"
appConfigFile = appConfigBase + appConfigExt
)
// AppConfig represents the application credentials configuration.
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
// This is stored in the edition-specific app config file, with the client
// secret securely stored in keychain when present.
type AppConfig struct {
ClientID string `json:"clientId"`
ClientSecret SecretInput `json:"clientSecret"`
@@ -53,9 +63,14 @@ var (
cachedResolvedMu sync.RWMutex
)
// GetAppConfigPath returns the path to the app config file.
// GetAppConfigPath returns the path to the app config file for the
// currently-active edition. The filename is partitioned by edition so that
// two dws binaries from different editions sharing the same configDir
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
// other's credentials. Open-source stays on "app.json" for backwards
// compatibility; sibling editions land on "app-<edition>.json".
func GetAppConfigPath(configDir string) string {
return filepath.Join(configDir, appConfigFile)
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
}
// LoadAppConfig loads the app configuration from disk.
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
cleanupLegacySiblingAppConfig(configDir, config)
// Update cache
cachedAppConfigMu.Lock()
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
return nil
}
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
return
}
legacyPath := filepath.Join(configDir, appConfigFile)
if legacyPath == GetAppConfigPath(configDir) {
return
}
data, err := os.ReadFile(legacyPath)
if err != nil {
return
}
var legacy AppConfig
if err := json.Unmarshal(data, &legacy); err != nil {
return
}
if legacy.ClientID != config.ClientID {
return
}
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
}
}
// DeleteAppConfig removes the app configuration and associated keychain secrets.
func DeleteAppConfig(configDir string) error {
// Load existing config to clean up keychain
+256
View File
@@ -0,0 +1,256 @@
// 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 auth
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Verifies that two dws binaries from different editions sharing the same
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
// app.json files. Without partitioning, a sibling edition's post-login
// persistence path could leak its pinned ClientID into the open-source
// build by reading the shared file.
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
for _, name := range []string{"", "open"} {
edition.Override(&edition.Hooks{Name: name})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", "app.json")
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
}
}
}
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
editionName string
wantFile string
}{
{"wukong", "app-wukong.json"},
{"dev", "app-dev.json"},
{"embedded", "app-embedded.json"},
}
for _, tc := range cases {
edition.Override(&edition.Hooks{Name: tc.editionName})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", tc.wantFile)
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
}
}
}
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
// End-to-end invariant: when the same configDir is observed from two
// different editions, the resulting app.json paths must NOT collide.
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
const cfg = "/tmp/shared-cfg"
edition.Override(&edition.Hooks{Name: "open"})
openPath := GetAppConfigPath(cfg)
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(cfg)
if openPath == wukongPath {
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
}
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
}
}
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(configDir)
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
t.Fatalf("writing sibling app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "open"})
got, err := LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig(open) error = %v", err)
}
if got != nil {
t.Fatalf("open edition read sibling app config: %#v", got)
}
}
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
t.Fatalf("sibling app config not written: %v", err)
}
}
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("different legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing malformed legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("malformed legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
siblingFiles := map[string][]byte{
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
}
for name, data := range siblingFiles {
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
t.Fatalf("writing sibling app config %s: %v", name, err)
}
}
edition.Override(&edition.Hooks{Name: "open"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
t.Fatalf("SaveAppConfig(open) error = %v", err)
}
for name, want := range siblingFiles {
got, err := os.ReadFile(filepath.Join(configDir, name))
if err != nil {
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
}
if string(got) != string(want) {
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
}
}
}
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
+45 -9
View File
@@ -19,6 +19,7 @@ import (
"os"
"strings"
"sync"
"unicode"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
return string(data), nil
}
// looksLikeFilePath returns true when value should be interpreted as the
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
// the character right after '@' must be ASCII (letter, digit, or one of the
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
// for natural-language messages that happen to start with '@' followed by
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
func looksLikeFilePath(value string) bool {
if !strings.HasPrefix(value, "@") || len(value) < 2 {
return false
}
rest := value[1:]
if rest == "-" {
return true // @- = stdin
}
first := rune(rest[0])
if first > unicode.MaxASCII {
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
return false
}
switch {
case first >= 'A' && first <= 'Z',
first >= 'a' && first <= 'z',
first >= '0' && first <= '9',
first == '.', first == '/', first == '~', first == '_', first == '-':
return true
}
return false
}
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
// Returns the original value unchanged if it does not start with "@".
// Returns the original value unchanged if it does not start with "@" or is
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
// text, not a path).
// Returns an error if the file cannot be read or exceeds the size limit.
//
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
func ReadFileArg(value string) (string, bool, error) {
if !strings.HasPrefix(value, "@") {
// Preserve the historical bare-"@" behaviour (empty filename → error).
if value == "@" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
if !looksLikeFilePath(value) {
return value, false, nil
}
path := value[1:]
if path == "" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
// @- is stdin, not a file — callers should use ResolveInputSource.
if path == "-" {
return value, false, nil
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
//
// The flagName parameter is used only for error messages and StdinGuard tracking.
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
if !strings.HasPrefix(value, "@") {
// Preserve the historical bare-"@" behaviour (empty filename → error).
if value == "@" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
}
if !looksLikeFilePath(value) {
// Pass through natural-language strings that happen to start with
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
return value, nil
}
path := value[1:]
if path == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
}
// @- reads from stdin.
if path == "-" {
+32
View File
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
}
}
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
// treated as a literal message, not as the @file injection syntax.
func TestReadFileArgChineseAtMention(t *testing.T) {
t.Parallel()
cases := []string{
"@所有人 这是 @ 所有人 的消息",
"@张三",
"@A 但接下来都是中文@测试",
}
for _, in := range cases {
val, isFile, err := ReadFileArg(in)
if in == "@A 但接下来都是中文@测试" {
// '@A' starts with ASCII, treated as path → expect file error
if err == nil {
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", in, err)
continue
}
if isFile {
t.Errorf("%q: should be plain text, got isFile=true", in)
}
if val != in {
t.Errorf("%q: got %q", in, val)
}
}
}
func TestReadFileArgReadsFile(t *testing.T) {
t.Parallel()
+35 -5
View File
@@ -236,10 +236,18 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, b := range built {
if b.parent == "" {
name := b.cmd.Name()
if _, exists := topLevel[name]; !exists {
if existing, exists := topLevel[name]; exists {
// Multiple servers contribute the same top-level command
// (e.g. group-chat and im both register `dws chat`). Move
// the incoming command's *children* into the existing top-
// level command instead of attaching the whole command (which
// would create `dws chat chat` because attachOrMerge would
// AddCommand(b.cmd) when no same-named sub exists).
mergeSubcommandsInto(existing, b.cmd)
} else {
topOrder = append(topOrder, name)
topLevel[name] = b.cmd
}
topLevel[name] = b.cmd
} else {
children = append(children, b)
}
@@ -248,12 +256,16 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
if parent, ok := topLevel[child.parent]; ok {
attachOrMerge(parent, child.cmd)
} else {
// Parent not found among dynamic commands; emit as top-level.
// Parent not found among dynamic commands; emit as top-level,
// merging into an existing same-named top-level command if one
// is already registered (same reasoning as the loop above).
name := child.cmd.Name()
if _, exists := topLevel[name]; !exists {
if existing, exists := topLevel[name]; exists {
mergeSubcommandsInto(existing, child.cmd)
} else {
topOrder = append(topOrder, name)
topLevel[name] = child.cmd
}
topLevel[name] = child.cmd
}
}
@@ -491,6 +503,24 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
return ensureNestedGroup(root, groupPath, groupPath, registry)
}
// mergeSubcommandsInto moves all sub-commands of src into dst, using
// attachOrMerge so subtree merges happen recursively. src itself is left
// empty after the call. Used when two envelope entries register the same
// top-level command (e.g. group-chat and im both `cli.command="chat"`):
// we want their *children* to coexist under one chat root, not have one
// nested inside the other.
func mergeSubcommandsInto(dst, src *cobra.Command) {
if dst == nil || src == nil {
return
}
subs := make([]*cobra.Command, len(src.Commands()))
copy(subs, src.Commands())
for _, sub := range subs {
src.RemoveCommand(sub)
attachOrMerge(dst, sub)
}
}
// attachOrMerge adds child as a sub-command of parent. If parent already has a
// sub-command with the same Name(), the two are merged recursively: child's
// sub-commands are moved onto the existing one and child itself is discarded.
+6 -6
View File
@@ -149,12 +149,12 @@ func executePipelineCall(
if result.Response == nil {
return map[string]any{}, nil
}
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
// cobra MarkFlagRequired) only checks that the flag was set, not
// that the value is non-empty — so a `--required-flag ""` reaches
// here and the upstream tool rejects with errorCode. Without this
// check the pipeline would happily proceed to poll/download and
// either spin until PollTimeout or burn through retries.
// Fail-fast on MCP business errors. Pre-execution validation (cobra
// MarkFlagRequired) only checks that the flag was set, not that
// the value is non-empty — so a `--required-flag ""` reaches here
// and the upstream tool rejects with errorCode. Without this check
// the pipeline proceeds to poll/download and either spins until
// PollTimeout or burns through retries.
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
msg := getDotPath(result.Response, "content.errorMessage")
return nil, apperrors.NewValidation(fmt.Sprintf(
+2 -2
View File
@@ -169,10 +169,10 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
strictMin = b.PositionalIndex + 1
}
}
var argsValidator cobra.PositionalArgs = cobra.NoArgs
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
switch {
case totalMax == 0:
argsValidator = cobra.NoArgs
argsValidator = cobra.ArbitraryArgs
case strictMin > 0 && strictMin == totalMax:
argsValidator = cobra.MinimumNArgs(strictMin)
case strictMin > 0:
+26 -1
View File
@@ -30,7 +30,7 @@ import (
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read.
// json_parse_strict, enum_map, file_read, invert_bool.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -47,6 +47,31 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformEnumMap(value, args)
case "file_read":
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
default:
return value, nil
}
}
// transformInvertBool flips a boolean: true → false, false → true. Strings
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
// `transform: invert_bool` and the framework flips at send time.
func transformInvertBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return !v, nil
case string:
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "true", "1", "yes", "on":
return false, nil
case "false", "0", "no", "off", "":
return true, nil
}
return value, nil
default:
return value, nil
}
+26
View File
@@ -259,3 +259,29 @@ func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
t.Errorf("expected pass-through, got %v", got)
}
}
func TestInvertBoolTransform(t *testing.T) {
cases := []struct {
in any
want any
}{
{true, false},
{false, true},
{"true", false},
{"false", true},
{"True", false},
{"FALSE", true},
{"on", false},
{"off", true},
{"", true},
}
for _, c := range cases {
got, err := ApplyTransform(c.in, "invert_bool", nil)
if err != nil {
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
}
}
}
+83 -299
View File
@@ -15,6 +15,7 @@ package helpers
import (
"context"
"encoding/json"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -30,6 +31,12 @@ func init() {
})
}
// chatHandler retains only the chat commands that carry real business logic
// (intelligent tool routing, current-user resolution, response normalization,
// or stdin/@file input support that dynamic commands do not yet provide).
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
// bot search — are now produced by the dynamic service-discovery envelope
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
type chatHandler struct{}
func (chatHandler) Name() string {
@@ -40,7 +47,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "chat",
Short: "群聊 / 消息 / 机器人",
Long: "管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。",
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
@@ -64,11 +71,12 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatMessageSendByBotCommand(runner),
newChatMessageRecallByBotCommand(runner),
newChatMessageSendByWebhookCommand(runner),
newChatMessageReplyCommand(runner),
)
bot := &cobra.Command{
Use: "bot",
Short: "机器人管理",
group := &cobra.Command{
Use: "group",
Short: "群组管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
@@ -76,9 +84,9 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
bot.AddCommand(newChatBotSearchCommand(runner))
group.AddCommand(newChatGroupCreateCommand(runner))
root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot)
root.AddCommand(message, group)
return root
}
@@ -93,11 +101,11 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
Example: ` dws chat message send --group <openconversation_id> --text "hello"
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
dws chat message send --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
@@ -128,7 +136,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (单聊必填,群聊可选)")
cmd.Flags().String("title", "", "消息标题 (必填,群聊与单聊都必填)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
@@ -196,10 +204,14 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
}
// Direct-message tools (send_direct_message_as_user) reject an empty title at
// the API level with a misleading "发群服务窗会话消息失败" error, so fail loudly
// here instead. Group messages do not require a title.
if (hasUser || hasOpenID) && strings.TrimSpace(title) == "" {
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
// reject an empty title at the API level with a misleading
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
// declares title as a required parameter on both tools.
if strings.TrimSpace(title) == "" {
if hasGroup {
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
}
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
}
@@ -266,94 +278,6 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newChatSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "根据名称搜索会话列表",
Example: ` dws chat search --query "项目冲刺"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
query, err := cmd.Flags().GetString("query")
if err != nil {
return apperrors.NewInternal("failed to read --query")
}
query = strings.TrimSpace(query)
if query == "" {
return apperrors.NewValidation("--query is required")
}
searchReq := map[string]any{"query": query}
cursor, err := cmd.Flags().GetString("cursor")
if err != nil {
return apperrors.NewInternal("failed to read --cursor")
}
if strings.TrimSpace(cursor) != "" {
searchReq["cursor"] = cursor
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
"search_groups_by_keyword",
map[string]any{"OpenSearchRequest": searchReq},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
return cmd
}
func newChatGroupCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "group",
Short: "群组管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
members := &cobra.Command{
Use: "members",
Short: "群成员管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Keeps the helper-restructured group winning over the dynamic envelope's
// `members` leaf (which only exposes `get_group_members`); without this
// the merge layer treats the shape mismatch as "envelope is authority"
// and drops the entire helper subtree (issue #164).
preferLegacyLeaf(members)
members.AddCommand(
newChatGroupMembersListCommand(runner),
newChatGroupMemberAddCommand(runner),
newChatGroupMemberRemoveCommand(runner),
newChatGroupMembersAddBotCommand(runner),
)
root.AddCommand(
newChatGroupCreateCommand(runner),
members,
newChatGroupRenameCommand(runner),
)
return root
}
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
@@ -643,6 +567,10 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
}
// ── message send-by-webhook ────────────────────────────────
//
// Kept as a helper (rather than delegating to the dynamic envelope) because
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
// dynamic-command layer does not yet provide.
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
@@ -709,65 +637,63 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
return cmd
}
// ── group members list ─────────────────────────────────────
// ── message reply ────────────────────────────────────────
//
// Kept as a helper because the underlying MCP tool send_personal_message
// requires the reply payload to be a JSON-encoded string assembled from
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
// flag→param mapping only and cannot construct nested JSON, so this
// orchestration must live in CLI code.
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询群成员列表",
Example: ` dws chat group members list --id <openconversation_id>`,
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。",
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
convID, _ := cmd.Flags().GetString("conversation-id")
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
refSender, _ := cmd.Flags().GetString("ref-sender")
text, _ := cmd.Flags().GetString("text")
if strings.TrimSpace(convID) == "" {
return apperrors.NewValidation("--conversation-id is required")
}
params := map[string]any{
"openconversation_id": groupID,
if strings.TrimSpace(refMsgID) == "" {
return apperrors.NewValidation("--ref-msg-id is required")
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
params["cursor"] = v
if strings.TrimSpace(refSender) == "" {
return apperrors.NewValidation("--ref-sender is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
))
if strings.TrimSpace(text) == "" {
return apperrors.NewValidation("--text is required")
}
replyContent := map[string]any{
"referenceOpenMessageId": refMsgID,
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": text,
}
contentJSON, err := jsonMarshal(replyContent)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
return cmd
}
// ── group rename ───────────────────────────────────────────
func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "rename",
Short: "更新群名称",
Example: ` dws chat group rename --id <openconversation_id> --name "新群名"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(name) == "" {
return apperrors.NewValidation("--name is required")
return apperrors.NewInternal("marshal reply content: " + err.Error())
}
params := map[string]any{
"openconversation_id": groupID,
"group_name": name,
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
"clawType": "wukong",
}
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
cobracmd.LegacyCommandPath(cmd),
"group-chat",
"send_personal_message",
params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
@@ -778,160 +704,18 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("name", "", "新群名称 (必填)")
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
return cmd
}
// ── group members add ──────────────────────────────────────
func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: "添加群成员",
Example: ` dws chat group members add --id <openconversation_id> --users userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
usersStr, _ := cmd.Flags().GetString("users")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(usersStr) == "" {
return apperrors.NewValidation("--users is required")
}
params := map[string]any{
"openconversation_id": groupID,
"userId": splitCSV(usersStr),
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)")
return cmd
}
// ── group members remove ───────────────────────────────────
func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: "移除群成员",
Example: ` dws chat group members remove --id <openconversation_id> --users userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
usersStr, _ := cmd.Flags().GetString("users")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(usersStr) == "" {
return apperrors.NewValidation("--users is required")
}
params := map[string]any{
"openconversationId": groupID,
"userIdList": splitCSV(usersStr),
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "Group ID / openconversation_id (required)")
cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)")
return cmd
}
// ── group members add-bot ──────────────────────────────────
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-bot",
Short: "Add bot to group",
Example: ` dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
robotCode, _ := cmd.Flags().GetString("robot-code")
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(robotCode) == "" {
return apperrors.NewValidation("--robot-code is required")
}
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
params := map[string]any{
"robotCode": robotCode,
"openConversationId": groupID,
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-code", "", "Bot code (required)")
cmd.Flags().String("id", "", "Group openConversationId (required)")
return cmd
}
// ── bot search ─────────────────────────────────────────────
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "Search my bots",
Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
page, _ := cmd.Flags().GetInt("page")
params := map[string]any{
"currentPage": page,
}
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
params["pageSize"] = v
}
if v, _ := cmd.Flags().GetString("name"); v != "" {
params["robotName"] = v
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Int("page", 1, "Page number, starting from 1")
cmd.Flags().Int("size", 0, "Items per page (default 50)")
cmd.Flags().String("name", "", "Search by name")
return cmd
return string(b), nil
}
+7 -73
View File
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
type captureRunner struct {
@@ -60,7 +59,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
}{
{
name: "group",
args: []string{"--group", "cid-xyz", "--text", "hello"},
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
wantTool: "send_message_as_user",
wantKey: "openConversation_id",
wantValue: "cid-xyz",
@@ -81,7 +80,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
},
{
name: "positional-text",
args: []string{"--group", "cid-xyz", "hello from positional"},
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
wantTool: "send_message_as_user",
wantKey: "text",
wantValue: "hello from positional",
@@ -132,6 +131,11 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
args: []string{"--group", "cid-x"},
wantErr: "--text (or positional argument) is required",
},
{
name: "group-without-title",
args: []string{"--group", "cid-x", "--text", "hi"},
wantErr: "--title is required for group messages",
},
{
name: "direct-user-without-title",
args: []string{"--user", "034766", "--text", "hi"},
@@ -302,76 +306,6 @@ func equalAny(a, b any) bool {
}
}
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
// added for issue #164: previously the bare `chat group members --id` was
// the list path, but it shape-mismatched the dynamic envelope's `members`
// leaf and got eaten by the merge layer. Now `dws chat group members list
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
func TestChatGroupMembersListSubcommand(t *testing.T) {
runner := &captureRunner{}
groupCmd := newChatGroupCommand(runner)
var members *cobra.Command
for _, sub := range groupCmd.Commands() {
if sub.Name() == "members" {
members = sub
break
}
}
if members == nil {
t.Fatalf("members subcommand missing under chat group")
}
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
for _, leaf := range members.Commands() {
if _, ok := want[leaf.Name()]; ok {
want[leaf.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` subcommand, missing", name)
}
}
if members.Flags().Lookup("id") != nil {
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
}
var listCmd *cobra.Command
for _, leaf := range members.Commands() {
if leaf.Name() == "list" {
listCmd = leaf
break
}
}
if listCmd == nil {
t.Fatalf("`list` subcommand not found")
}
if listCmd.Flags().Lookup("id") == nil {
t.Errorf("`list` subcommand must declare --id")
}
if listCmd.Flags().Lookup("cursor") == nil {
t.Errorf("`list` subcommand must declare --cursor")
}
// Drive execution via the group root so cobra resolves the subcommand
// path properly (calling Execute() on a child directly would re-enter
// the root help branch).
var out bytes.Buffer
groupCmd.SetOut(&out)
groupCmd.SetErr(&out)
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
if err := groupCmd.Execute(); err != nil {
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
}
if got := runner.last.Tool; got != "get_group_members" {
t.Fatalf("Tool = %q, want get_group_members", got)
}
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
}
}
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
cases := []struct {
name string
+39 -6
View File
@@ -647,18 +647,24 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor {
descriptor.UpdatedAt = updatedAt
}
existing, exists := bestByEndpoint[descriptor.Key]
// Dedup key includes cli.id when present so that envelopes
// intentionally splitting one MCP endpoint into multiple CLI command
// trees (e.g. bot-root / bot-message / bot-group all serving
// .../server/4717... but exposing different command roots) are not
// collapsed by endpoint-only dedup. Without cli.id the key falls
// back to descriptor.Key (= endpoint) for backwards compatibility.
endpointKey := dedupKeyForEndpoint(descriptor)
existing, exists := bestByEndpoint[endpointKey]
if !exists || descriptorIsNewer(descriptor, existing) {
bestByEndpoint[descriptor.Key] = descriptor
bestByEndpoint[endpointKey] = descriptor
}
}
bestByName := make(map[string]ServerDescriptor)
for _, descriptor := range bestByEndpoint {
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
if nameKey == "" {
nameKey = descriptor.Key
}
// Same reasoning as endpoint dedup: append cli.id so three bot-*
// entries with displayName "机器人消息" don't collapse into one.
nameKey := dedupKeyForName(descriptor)
existing, exists := bestByName[nameKey]
if !exists || descriptorIsNewer(descriptor, existing) {
bestByName[nameKey] = descriptor
@@ -704,6 +710,33 @@ func normalizeDisplayNameKey(displayName string) string {
return strings.ToLower(strings.TrimSpace(displayName))
}
// dedupKeyForEndpoint returns the dedup key used when collapsing multiple
// envelope entries that share an MCP endpoint. cli.id is appended so an
// endpoint intentionally fronting multiple CLI command roots (bot-root /
// bot-message / bot-group all served by the same MCP server) stays as
// distinct descriptors. When cli.id is empty (or absent), the key is the
// endpoint alone to preserve historical dedup behaviour.
func dedupKeyForEndpoint(descriptor ServerDescriptor) string {
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
return descriptor.Key + "#" + cliID
}
return descriptor.Key
}
// dedupKeyForName mirrors dedupKeyForEndpoint for the second-pass name-based
// dedup so two envelopes with the same displayName but distinct cli.id (the
// bot-* trio shares displayName "机器人消息") remain separate.
func dedupKeyForName(descriptor ServerDescriptor) string {
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
if nameKey == "" {
nameKey = descriptor.Key
}
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
return nameKey + "#" + cliID
}
return nameKey
}
func markDeprecatedCandidate(displayName string, lifecycle LifecycleInfo) LifecycleInfo {
if lifecycle.DeprecatedCandidate {
return lifecycle
+59
View File
@@ -101,6 +101,65 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
}
}
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
// from the CLI after NormalizeServers collapsed three envelopes that share a
// single MCP endpoint and displayName but expose different cli.id values).
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
t.Parallel()
sharedURL := "https://example.com/server/4717"
sharedName := "机器人消息"
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 3 {
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
}
seen := map[string]bool{}
for _, s := range servers {
seen[s.CLI.ID] = true
}
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
if !seen[want] {
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
}
}
}
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
t.Parallel()
+20 -5
View File
@@ -74,23 +74,38 @@ const (
DefaultPartition = "default/default"
)
// EditionPartition returns the cache partition for a given edition name.
// The open-source core (name == "" or "open") uses DefaultPartition; every
// other edition gets its own namespace to prevent cross-edition data
// leakage in the disk cache.
// IsOpenEdition reports whether an edition name maps to the open-source core.
//
// This helper takes the edition name as a parameter instead of calling
// edition.Get() so that pkg/config remains a leaf dependency — importable
// from internal/cli, internal/app, internal/cache, etc. without risking
// import cycles.
func IsOpenEdition(name string) bool {
name = strings.TrimSpace(name)
return name == "" || name == "open"
}
// EditionPartition returns the cache partition for a given edition name.
// The open-source core (name == "" or "open") uses DefaultPartition; every
// other edition gets its own namespace to prevent cross-edition data
// leakage in the disk cache.
func EditionPartition(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "open" {
if IsOpenEdition(name) {
return DefaultPartition
}
return name + "/default"
}
// EditionFileName returns the edition-partitioned file name for base+ext.
func EditionFileName(name, base, ext string) string {
name = strings.TrimSpace(name)
if IsOpenEdition(name) {
return base + ext
}
return base + "-" + name + ext
}
// ── Auth flow timeouts ──────────────────────────────────────────────────
const (
+42
View File
@@ -96,6 +96,48 @@ func TestEditionPartition(t *testing.T) {
}
}
func TestIsOpenEdition(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want bool
}{
{"empty is open", "", true},
{"open is open", "open", true},
{"whitespace open is open", " open ", true},
{"wukong is sibling", "wukong", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsOpenEdition(tc.input); got != tc.want {
t.Fatalf("IsOpenEdition(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
func TestEditionFileName(t *testing.T) {
t.Parallel()
cases := []struct {
name string
edition string
want string
}{
{"empty uses legacy filename", "", "app.json"},
{"open uses legacy filename", "open", "app.json"},
{"whitespace is trimmed", " wukong ", "app-wukong.json"},
{"sibling is suffixed", "wukong", "app-wukong.json"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := EditionFileName(tc.edition, "app", ".json"); got != tc.want {
t.Fatalf("EditionFileName(%q, \"app\", \".json\") = %q, want %q", tc.edition, got, tc.want)
}
})
}
}
func TestManualTokenExpiry(t *testing.T) {
t.Parallel()
if ManualTokenExpiry <= 0 {
+9 -8
View File
@@ -183,7 +183,7 @@ Flags:
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
@@ -191,15 +191,15 @@ Flags:
Usage:
dws chat message send [flags] [<text>]
Example:
dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
dws chat message send --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "通知" "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
dws chat message send --group <openconversation_id> --title "通知" --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --title "图片" --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --title "图片" --media-id <mediaId>
Flags:
--text string 消息内容(推荐使用,也可用位置参数)
--group string 群聊 openconversation_id(群聊时必填)
@@ -219,6 +219,7 @@ Flags:
注意:
- --text 和位置参数二选一,--text 优先
- --title 必填(群聊与单聊都必填,API 强制要求)
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
@@ -722,7 +723,7 @@ dws drive download --file-id <dentryUuid> --format json
# Step 5: 用 Markdown 图片语法发送
dws chat message send --group <openconversation_id> \
--text "![截图](下载链接)" --format json
--title "截图" --text "![截图](下载链接)" --format json
```
## 上下文传递表