diff --git a/.changes/954-chat-im-id-flags.md b/.changes/954-chat-im-id-flags.md new file mode 100644 index 00000000..ae77c7fa --- /dev/null +++ b/.changes/954-chat-im-id-flags.md @@ -0,0 +1,7 @@ +--- +category: Changed +--- + +- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags. +- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces. +- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name. diff --git a/.changes/released/1.0.59-beta.1/899-drive-latest-incomplete-scan.md b/.changes/released/1.0.59-beta.1/899-drive-latest-incomplete-scan.md new file mode 100644 index 00000000..cc16c576 --- /dev/null +++ b/.changes/released/1.0.59-beta.1/899-drive-latest-incomplete-scan.md @@ -0,0 +1,20 @@ +--- +category: Fixed +--- + +- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to + exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read + failed mid-recursion (permission denied, API error), letting an incomplete set pose as the + globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory + failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report + the first failing folder with its depth and reason, and emit a recovery command that + reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`, + `--start` and `--end` are all carried over. On POSIX shells each user-supplied value is + quoted so a URL query string or a shell metacharacter cannot change how the copied command + parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values + containing metacharacters are not inlined at all: the command carries a placeholder and the + original value is shown on a separate line marked as data rather than an executable command. + Unrecoverable errors under `--latest` return the root cause instead of a partial result. + Remote-controlled folder names and server error text are stripped of ANSI escapes and + control characters before they reach the plain-text stderr message. The internal `sortTime` + sort key no longer leaks into `drive list --depth` output on any path. diff --git a/.changes/942-drive-list-filter.md b/.changes/released/1.0.59-beta.1/942-drive-list-filter.md similarity index 100% rename from .changes/942-drive-list-filter.md rename to .changes/released/1.0.59-beta.1/942-drive-list-filter.md diff --git a/.changes/942-drive-list-pattern-fix.md b/.changes/released/1.0.59-beta.1/942-drive-list-pattern-fix.md similarity index 100% rename from .changes/942-drive-list-pattern-fix.md rename to .changes/released/1.0.59-beta.1/942-drive-list-pattern-fix.md diff --git a/.changes/973-chat-message-time-defaults.md b/.changes/released/1.0.59-beta.1/973-chat-message-time-defaults.md similarity index 100% rename from .changes/973-chat-message-time-defaults.md rename to .changes/released/1.0.59-beta.1/973-chat-message-time-defaults.md diff --git a/.changes/doc-drive-param-aliases.md b/.changes/released/1.0.59-beta.1/doc-drive-param-aliases.md similarity index 100% rename from .changes/doc-drive-param-aliases.md rename to .changes/released/1.0.59-beta.1/doc-drive-param-aliases.md diff --git a/.changes/drive-folder-sync.md b/.changes/released/1.0.59-beta.1/drive-folder-sync.md similarity index 100% rename from .changes/drive-folder-sync.md rename to .changes/released/1.0.59-beta.1/drive-folder-sync.md diff --git a/.changes/released/1.0.59-beta.1/global-dingtalk-region.md b/.changes/released/1.0.59-beta.1/global-dingtalk-region.md new file mode 100644 index 00000000..b488979d --- /dev/null +++ b/.changes/released/1.0.59-beta.1/global-dingtalk-region.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow. diff --git a/.changes/open-dingtalk-id-format-routing.md b/.changes/released/1.0.59-beta.1/open-dingtalk-id-format-routing.md similarity index 100% rename from .changes/open-dingtalk-id-format-routing.md rename to .changes/released/1.0.59-beta.1/open-dingtalk-id-format-routing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0656ba9c..0a1f2dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.1] - 2026-08-14 + +### Added + +- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type + file|folder`, `--start`, and `--end` for client-side filtering by node type + and modification time on both the pan and workspace routes. Filtering runs + a bounded full scan of the target directory (2000-entry cap, reported via + `truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is + mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/ + `--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339, + zone-less ISO 8601 (Asia/Shanghai), or a plain date. + +- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`, + `dws drive push`, and `dws drive sync` for file-level comparison and transfer + between a local folder and a Drive folder. Differences come from exact MD5 by + default or from modification time with `--quick`; `status` is read-only, `pull` + and `push` are one-directional with `--if-exists skip|smart|overwrite`, and + `sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`. + Only regular files are transferred — online documents and shortcuts are skipped, + neither side deletes extra files, downloads are staged through a temporary file + and committed with an atomic rename, and remote names that would escape + `--local-folder` are reported as failures instead of being written. Every command + prints a structured summary on stdout and exits non-zero when any item fails. + +- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow. + +### Changed + +- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts. + +### Fixed + +- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to + exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read + failed mid-recursion (permission denied, API error), letting an incomplete set pose as the + globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory + failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report + the first failing folder with its depth and reason, and emit a recovery command that + reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`, + `--start` and `--end` are all carried over. On POSIX shells each user-supplied value is + quoted so a URL query string or a shell metacharacter cannot change how the copied command + parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values + containing metacharacters are not inlined at all: the command carries a placeholder and the + original value is shown on a separate line marked as data rather than an executable command. + Unrecoverable errors under `--latest` return the root cause instead of a partial result. + Remote-controlled folder names and server error text are stripped of ANSI escapes and + control characters before they reach the plain-text stderr message. The internal `sortTime` + sort key no longer leaks into `drive list --depth` output on any path. + +- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the + single-layer pan route now filters the returned page by name pattern; the + flag was previously accepted but silently ignored. + +- **Drive list `--type folder --latest` composition** (#942) — `--latest` now + ranks the filtered entries (folders included when `--type folder` is set) + instead of unconditionally dropping folders, so the documented combination + returns the most recently modified folders rather than an empty list. + +- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows. + +- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch. + + ## [1.0.58] - 2026-08-13 This release promotes the sealed `v1.0.58-beta.6` contents to stable. diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 1ee3c02a..98fc206f 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -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.58-beta.6" + version "1.0.59-beta.1" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz" - sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz" + sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz" - sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz" + sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz" - sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz" + sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz" - sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz" + sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip" - sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip" + sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b" end def install diff --git a/README.md b/README.md index b29410ae..ec3e7416 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step ## Reference & Docs +- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting - [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance - [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion - [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer diff --git a/README_zh.md b/README_zh.md index b61cb62d..e47544a4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id --robot-client-secret \ + --client-secret +``` + +This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs. + +## Verify the login + +```bash +dws auth status --format json +dws profile list --format json +dws contact user get-self +``` + +The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform. + +## Use domestic and international profiles together + +```bash +# Domestic (.com) +dws auth login + +# International (.io) +dws auth login --intl + +# Find the stable profile selectors +dws profile list --format json +``` + +Persistently switch profiles: + +```bash +dws profile switch : +``` + +Toggle back to the previous profile: + +```bash +dws profile switch - +``` + +Select a profile for one command without changing the default: + +```bash +dws --profile : contact user get-self +``` + +Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region. + +## Isolated smoke testing + +Use a separate configuration directory to avoid changing the normal `~/.dws` login state: + +```bash +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self +``` + +Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release. + +## Pre-release overrides (maintainers only) + +Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`. + +Maintainers can test the pre-release login/MCP pair with: + +```bash +dws auth login --intl --pre-url https://pre-login.dingtalk.io +``` + +A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login. + +Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination. + +## Troubleshooting + +### The browser still opens a `.com` page + +1. Run `dws auth login --help` and confirm `--intl` is present. +2. For a source checkout, use `./dws` instead of an older installed binary. +3. Confirm the executed command is `dws auth login --intl`. + +### A business command appears to use the wrong region + +Run `dws profile list --format json`, then switch with the exact `:` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account. + +### Login succeeds but the command reports missing permission + +This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure. + +### Should I edit `~/.dws/mcp_url` manually? + +No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment. + +## Command reference + +| Scenario | Command | +|---|---| +| Domestic browser login | `dws auth login` | +| International browser login | `dws auth login --intl` | +| International device login | `dws auth login --intl --device` | +| Check auth state | `dws auth status --format json` | +| List profiles | `dws profile list --format json` | +| Persistently switch profile | `dws profile switch :` | +| Toggle to previous profile | `dws profile switch -` | +| Select a profile once | `dws --profile : ` | diff --git a/docs/international-region-guide.zh-CN.md b/docs/international-region-guide.zh-CN.md new file mode 100644 index 00000000..8ad9d3ac --- /dev/null +++ b/docs/international-region-guide.zh-CN.md @@ -0,0 +1,185 @@ +# DWS 国际版(DingTalk `.io`)使用手册 + +本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。 + +## 核心规则 + +- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。 +- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。 +- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。 +- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。 +- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。 + +## 确认当前版本支持国际版 + +运行: + +```bash +dws auth login --help +``` + +帮助中应包含: + +```text +--intl +--international +``` + +从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本: + +```bash +make build +./dws auth login --help +``` + +## 国际版登录 + +### 浏览器登录 + +```bash +dws auth login --intl +``` + +DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。 + +### 设备码登录 + +适用于 SSH、容器或没有可用浏览器的环境: + +```bash +dws auth login --intl --device +``` + +按照终端提示,在另一台可打开浏览器的设备上完成授权。 + +### 使用自有应用凭证完成用户 OAuth + +```bash +dws auth login --intl \ + --client-id \ + --client-secret +``` + +该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。 + +## 验证登录和业务调用 + +查看当前登录状态: + +```bash +dws auth status --format json +``` + +列出本机全部账号并找到当前 profile: + +```bash +dws profile list --format json +``` + +执行一个只读命令验证国际链路,例如: + +```bash +dws contact user get-self +``` + +登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。 + +## 国内版和国际版账号并存 + +可以在同一台机器上分别登录国内版和国际版账号: + +```bash +# 国内版(.com) +dws auth login + +# 国际版(.io) +dws auth login --intl + +# 查看稳定的 profile 选择器 +dws profile list --format json +``` + +持久切换账号: + +```bash +dws profile switch : +``` + +切回上一个账号: + +```bash +dws profile switch - +``` + +只为单次命令指定账号,不修改默认账号: + +```bash +dws --profile : contact user get-self +``` + +DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。 + +## 使用独立配置目录进行验证 + +如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录: + +```bash +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self +``` + +请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。 + +## 预发参数(仅维护者) + +普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。 + +维护者验证预发登录/MCP 链路时可以使用: + +```bash +dws auth login --intl --pre-url https://pre-login.dingtalk.io +``` + +也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。 + +预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。 + +## 常见问题 + +### 仍然打开 `.com` 登录页面 + +1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。 +2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。 +3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。 + +### 业务命令似乎使用了错误区域 + +先检查当前账号: + +```bash +dws profile list --format json +``` + +然后使用精确的 `:` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。 + +### 登录成功但提示没有权限 + +这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。 + +### 是否需要手工修改 `~/.dws/mcp_url` + +不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。 + +## 命令速查 + +| 场景 | 命令 | +|---|---| +| 国内版浏览器登录 | `dws auth login` | +| 国际版浏览器登录 | `dws auth login --intl` | +| 国际版设备码登录 | `dws auth login --intl --device` | +| 查看登录状态 | `dws auth status --format json` | +| 查看所有账号 | `dws profile list --format json` | +| 持久切换账号 | `dws profile switch :` | +| 切回上一个账号 | `dws profile switch -` | +| 单次指定账号 | `dws --profile : ` | diff --git a/internal/app/access_token_manager_test.go b/internal/app/access_token_manager_test.go index e996b7be..358015d2 100644 --- a/internal/app/access_token_manager_test.go +++ b/internal/app/access_token_manager_test.go @@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te token := "token-a" installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { calls.Add(1) - return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil + return &authpkg.TokenData{ + AccessToken: token, + ExpiresAt: time.Now().Add(time.Hour), + LoginRegion: string(authpkg.LoginRegionInternational), + }, nil }) manager := NewTokenManager() first, err := manager.Get(context.Background(), configDir, "") - if err != nil || first.AccessToken != "token-a" { + if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown { t.Fatalf("first token = %#v, %v", first, err) } second, err := manager.Get(context.Background(), configDir, "") diff --git a/internal/app/access_token_resolve.go b/internal/app/access_token_resolve.go index c5c969d9..df605673 100644 --- a/internal/app/access_token_resolve.go +++ b/internal/app/access_token_resolve.go @@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface { // AccessTokenSnapshot is the minimal bearer view needed by the process cache. // Refresh-token material never leaves the auth package. type AccessTokenSnapshot struct { - AccessToken string - ExpiresAt time.Time - Source string + AccessToken string + ExpiresAt time.Time + Source string + LoginRegion authpkg.LoginRegion + LoginRegionKnown bool } type tokenManagerKey struct { @@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s data, err := snapshotProvider.GetTokenSnapshot(ctx) if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" { return AccessTokenSnapshot{ - AccessToken: strings.TrimSpace(data.AccessToken), - ExpiresAt: data.ExpiresAt, - Source: "oauth", + AccessToken: strings.TrimSpace(data.AccessToken), + ExpiresAt: data.ExpiresAt, + Source: "oauth", + LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)), + LoginRegionKnown: true, }, nil } if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { diff --git a/internal/app/agent_metadata_test.go b/internal/app/agent_metadata_test.go index 2bae8254..3caaa926 100644 --- a/internal/app/agent_metadata_test.go +++ b/internal/app/agent_metadata_test.go @@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) { // calls must fail before preflight or transport while anonymous plugins remain // valid above. resolveCalled := false - testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) { + testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) { resolveCalled = true - return "", nil + return AccessTokenSnapshot{}, nil }) callsBefore := len(captured) unauthenticated := &runtimeRunner{ diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e887341b..c914d755 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -20,6 +20,8 @@ import ( "encoding/json" "fmt" "io" + "net" + "net/url" "os" "path/filepath" "runtime" @@ -48,8 +50,24 @@ type authLoginConfig struct { TargetCorpID string HistoryProfileSelector string HistoryProfileSelectorExplicit bool + International bool + PreURL string + MCPURL string } +type authLoginEndpointOverrides struct { + LoginURL string + MCPURL string +} + +type authLoginMCPPersistence uint8 + +const ( + authLoginMCPUseDefault authLoginMCPPersistence = iota + authLoginMCPUseManagedRegion + authLoginMCPUseExplicitOverride +) + type authLoginGuideAction string const ( @@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 支持的登录方式: - OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成 - OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境 + - 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权 - 直接提供 Token (--token): 跳过授权,使用已有 token 不支持的登录方式: - 邮箱/密码登录 - 手机号/验证码登录 - - 应用凭证 (AppKey/AppSecret) 直接登录 + - 无用户授权的纯应用凭证 (client_credentials) 登录 + +区域: + - 默认使用国内钉钉 .com 登录与服务端点 + - --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由 注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device, 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 @@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 示例: dws auth login # 本机登录并新增/刷新一个组织 profile dws auth login --profile # 指定本次授权目标组织,不持久切换当前组织 + dws auth login --intl # 使用钉钉国际版 .io 登录入口 + dws auth login --intl --pre-url https://pre-login.dingtalk.io + dws auth login --intl --pre-url https://pre-mcp.dingtalk.io dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 @@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if err != nil { return err } + var preOverrides authLoginEndpointOverrides + if cfg.PreURL != "" { + var err error + preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) + if err != nil { + return err + } + restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL) + defer restoreLoginBaseURL() + } + mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides) + if err != nil { + return err + } + restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL) + defer restoreMCPBaseURL() configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") @@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { AccessToken: cfg.Token, ExpiresAt: time.Now().Add(config.ManualTokenExpiry), } + if cfg.International { + tokenData.LoginRegion = string(authpkg.LoginRegionInternational) + } if err := authSaveTokenData(configDir, tokenData); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) } @@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider := authpkg.NewDeviceFlowProvider(configDir, nil) provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") + if cfg.International { + provider.SetLoginRegion(authpkg.LoginRegionInternational) + } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, @@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID + if cfg.International { + provider.LoginRegion = authpkg.LoginRegionInternational + } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, @@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } } + if tokenData != nil { + if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err)) + } + } ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() @@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") + cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints") + cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints") + cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login") + cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login") cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程") cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags @@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command { return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err)) } _ = authRemove(filepath.Join(configDir, "mcp_url")) + _ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) _ = authRemove(filepath.Join(configDir, "token")) _ = authDeleteAppConfig(configDir) ResetRuntimeTokenCache() @@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --device") } + intl, err := cmd.Flags().GetBool("intl") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --intl") + } + international, err := cmd.Flags().GetBool("international") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --international") + } force, err := cmd.Flags().GetBool("force") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") @@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") } + preURL, err := cmd.Flags().GetString("pre-url") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url") + } + mcpURL, err := cmd.Flags().GetString("mcp-url") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url") + } yes := false profileSelector := "" if cmd.Root() != nil { @@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { TargetCorpID: targetCorpID, HistoryProfileSelector: historyProfileSelector, HistoryProfileSelectorExplicit: historyProfileSelectorExplicit, + International: intl || international, + PreURL: strings.TrimSpace(preURL), + MCPURL: strings.TrimSpace(mcpURL), }, nil } +func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) { + parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url") + if err != nil { + return authLoginEndpointOverrides{}, err + } + host := strings.ToLower(parsed.Hostname()) + switch { + case strings.HasPrefix(host, "pre-login."): + return authLoginEndpointOverrides{ + LoginURL: normalized, + MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")), + }, nil + case strings.HasPrefix(host, "pre-mcp."): + return authLoginEndpointOverrides{ + LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")), + MCPURL: normalized, + }, nil + default: + return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL") + } +} + +func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) { + if cfg.MCPURL != "" { + _, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url") + if err != nil { + return "", authLoginMCPUseDefault, err + } + return normalized, authLoginMCPUseExplicitOverride, nil + } + if cfg.PreURL != "" { + if preOverrides.MCPURL == "" { + var err error + preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) + if err != nil { + return "", authLoginMCPUseDefault, err + } + } + return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil + } + if cfg.International { + return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil + } + return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil +} + +func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error { + mcpURLPath := filepath.Join(configDir, "mcp_url") + managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) + + switch persistence { + case authLoginMCPUseExplicitOverride: + if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil { + return fmt.Errorf("clear managed MCP region: %w", err) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("save explicit MCP URL: %w", err) + } + return nil + case authLoginMCPUseManagedRegion: + managedURL, managedErr := authReadFile(managedRegionPath) + if managedErr != nil && !os.IsNotExist(managedErr) { + return fmt.Errorf("read managed MCP region: %w", managedErr) + } + currentURL, currentErr := authReadFile(mcpURLPath) + switch { + case currentErr == nil && os.IsNotExist(managedErr): + return nil + case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)): + return removeAuthLoginManagedMCPRegion(managedRegionPath) + case currentErr != nil && !os.IsNotExist(currentErr): + return fmt.Errorf("read MCP URL: %w", currentErr) + } + if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("save managed MCP region: %w", err) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + _ = authRemove(managedRegionPath) + return fmt.Errorf("save managed MCP URL: %w", err) + } + return nil + case authLoginMCPUseDefault: + managedURL, err := authReadFile(managedRegionPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read managed MCP region: %w", err) + } + currentURL, err := authReadFile(mcpURLPath) + if os.IsNotExist(err) { + return removeAuthLoginManagedMCPRegion(managedRegionPath) + } + if err != nil { + return fmt.Errorf("read MCP URL: %w", err) + } + if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) { + return removeAuthLoginManagedMCPRegion(managedRegionPath) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("restore default MCP URL: %w", err) + } + return removeAuthLoginManagedMCPRegion(managedRegionPath) + default: + return fmt.Errorf("unsupported MCP persistence mode %d", persistence) + } +} + +func removeAuthLoginManagedMCPRegion(path string) error { + if err := authRemove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return nil, "", apperrors.NewValidation(flagName + " cannot be empty") + } + if !strings.Contains(value, "://") { + value = "https://" + value + } + parsed, err := url.Parse(value) + if err != nil { + return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err)) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, "", apperrors.NewValidation(flagName + " must use http or https") + } + if parsed.Hostname() == "" { + return nil, "", apperrors.NewValidation(flagName + " must include a host") + } + if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) { + return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint") + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed, strings.TrimRight(parsed.String(), "/"), nil +} + +func isAuthLoginLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), "localhost") { + return true + } + ip := net.ParseIP(strings.TrimSpace(host)) + return ip != nil && ip.IsLoopback() +} + +func authLoginURLWithHost(parsed *url.URL, host string) string { + copyURL := *parsed + if port := parsed.Port(); port != "" { + copyURL.Host = net.JoinHostPort(host, port) + } else { + copyURL.Host = host + } + return strings.TrimRight(copyURL.String(), "/") +} + func authLoginForcesAuthorization(_ authLoginConfig) bool { return true } diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index b572a375..2afdd00f 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "testing" "time" @@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) { } func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) oldSave := authSaveTokenData oldDevice := authDeviceLogin oldOAuth := authOAuthLogin @@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) { t.Fatalf("json token login = %q, %v", out, err) } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil { + t.Fatal("invalid pre-release host should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil { + t.Fatal("remote plaintext MCP URL should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil { + t.Fatalf("pre-release token login = %v", err) + } authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { return nil, errors.New("device") @@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { t.Fatal(err) } + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("device login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil { + t.Fatalf("international device login = %v", err) + } authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { return nil, errors.New("oauth") @@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") { t.Fatalf("oauth success = %q, %v", out, err) } + authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("OAuth login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil { + t.Fatalf("international OAuth login = %v", err) + } + + blockedConfigDir := t.TempDir() + if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", blockedConfigDir) + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") { + t.Fatalf("MCP URL persist failure = %v", err) + } + t.Setenv("DWS_CONFIG_DIR", configDir) authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { return errors.New("recommend") @@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T) authRemove = func(string) error { removed++; return errors.New("ignored") } authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") } edition.Override(&edition.Hooks{}) - if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") { + if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") { t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed) } edition.Override(&edition.Hooks{IsEmbedded: true}) diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 486cd98a..31371e4a 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -33,6 +33,8 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := root.PersistentFlags().Set("yes", "true"); err != nil { @@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { + for _, flag := range []string{"intl", "international"} { + t.Run(flag, func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set(flag, "true"); err != nil { + t.Fatalf("set %s: %v", flag, err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if !cfg.International { + t.Fatalf("International = false for --%s, want true", flag) + } + }) + } + + for _, tc := range []struct { + name string + setup func(*cobra.Command) + }{ + { + name: "missing intl", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + }, + }, + { + name: "missing international", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + }, + }, + { + name: "missing pre url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + }, + }, + { + name: "missing mcp url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + cmd.Flags().String("pre-url", "", "") + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "login"} + tc.setup(cmd) + if _, err := resolveAuthLoginConfig(cmd); err == nil { + t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set") + } + }) + } +} + +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil { + t.Fatalf("set mcp-url: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" { + t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL) + } +} + +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil { + t.Fatalf("set pre-url: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if cfg.PreURL != "pre-login.dingtalk.io" { + t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL) + } +} + +func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) { + tests := []struct { + name string + raw string + wantLogin string + wantMCP string + }{ + { + name: "pre login", + raw: "https://pre-login.dingtalk.io/", + wantLogin: "https://pre-login.dingtalk.io", + wantMCP: "https://pre-mcp.dingtalk.io", + }, + { + name: "pre mcp", + raw: "pre-mcp.dingtalk.io", + wantLogin: "https://pre-login.dingtalk.io", + wantMCP: "https://pre-mcp.dingtalk.io", + }, + { + name: "pre login with port", + raw: "https://pre-login.dingtalk.io:8443/path/", + wantLogin: "https://pre-login.dingtalk.io:8443/path", + wantMCP: "https://pre-mcp.dingtalk.io:8443/path", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := authLoginEndpointOverridesForPreURL(tc.raw) + if err != nil { + t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err) + } + if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP { + t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP) + } + }) + } + + for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} { + if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil { + t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw) + } + } +} + +func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) { + tests := []struct { + name string + cfg authLoginConfig + preOverride authLoginEndpointOverrides + wantURL string + wantPersistence authLoginMCPPersistence + }{ + { + name: "default center login uses com and resets only managed region", + cfg: authLoginConfig{}, + wantURL: authpkg.DefaultMCPBaseURL, + wantPersistence: authLoginMCPUseDefault, + }, + { + name: "international login persists managed io", + cfg: authLoginConfig{International: true}, + wantURL: authpkg.InternationalMCPBaseURL, + wantPersistence: authLoginMCPUseManagedRegion, + }, + { + name: "pre login persists mapped pre mcp", + cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"}, + preOverride: authLoginEndpointOverrides{ + LoginURL: "https://pre-login.dingtalk.io", + MCPURL: "https://pre-mcp.dingtalk.io", + }, + wantURL: "https://pre-mcp.dingtalk.io", + wantPersistence: authLoginMCPUseExplicitOverride, + }, + { + name: "explicit mcp url wins over pre url", + cfg: authLoginConfig{ + PreURL: "pre-login.dingtalk.io", + MCPURL: " https://custom-mcp.example.com/ ", + }, + preOverride: authLoginEndpointOverrides{ + LoginURL: "https://pre-login.dingtalk.io", + MCPURL: "https://pre-mcp.dingtalk.io", + }, + wantURL: "https://custom-mcp.example.com", + wantPersistence: authLoginMCPUseExplicitOverride, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride) + if err != nil { + t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err) + } + if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence { + t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence) + } + }) + } + + for _, cfg := range []authLoginConfig{ + {MCPURL: "http://remote.example.com"}, + {PreURL: "https://example.com"}, + } { + if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil { + t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg) + } + } +} + +func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) { + t.Run("persists selected io mcp url", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil { + t.Fatalf("ReadFile(mcp_url) error = %v", err) + } + if string(data) != authpkg.InternationalMCPBaseURL { + t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL) + } + managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) + if err != nil || string(managed) != authpkg.InternationalMCPBaseURL { + t.Fatalf("managed MCP region = %q, %v", string(managed), err) + } + }) + + t.Run("center login preserves previous persisted override", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + const customURL = "https://custom-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil { + t.Fatalf("ReadFile(mcp_url) error = %v", err) + } + if string(data) != customURL { + t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL) + } + }) + + t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("unmanaged override acquired a managed marker: %v", err) + } + }) + + t.Run("center login resets a managed international URL", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != authpkg.DefaultMCPBaseURL { + t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("managed region marker remains after domestic login: %v", err) + } + }) + + t.Run("explicit override clears region ownership", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + const customURL = "https://custom-mcp.example.com" + if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) + } + }) + + t.Run("stale marker never deletes a different custom URL", func(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != "https://custom.example.com" { + t.Fatalf("custom mcp_url = %q, %v", string(data), err) + } + }) + + t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil || string(data) != customURL { + t.Fatalf("custom mcp_url = %q, %v", string(data), err) + } + if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) { + t.Fatalf("stale managed marker remains: %v", err) + } + }) +} + +func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) { + fail := errors.New("persist failure") + + t.Run("explicit marker cleanup", func(t *testing.T) { + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { + t.Fatalf("explicit marker cleanup error = %v", err) + } + }) + + t.Run("explicit URL write", func(t *testing.T) { + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { + t.Fatalf("explicit URL write error = %v", err) + } + }) + + t.Run("managed marker write", func(t *testing.T) { + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed marker write error = %v", err) + } + }) + + t.Run("managed marker read", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed marker read error = %v", err) + } + }) + + t.Run("managed MCP URL read", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return nil, os.ErrNotExist + } + return nil, fail + }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed MCP URL read error = %v", err) + } + }) + + t.Run("managed stale marker cleanup", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.DefaultMCPBaseURL), nil + } + return []byte("https://private-mcp.example.com"), nil + }) + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed stale marker cleanup error = %v", err) + } + }) + + t.Run("managed URL write cleans marker", func(t *testing.T) { + writes := 0 + removed := false + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { + writes++ + if writes == 2 { + return fail + } + return nil + }) + testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed { + t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed) + } + }) + + t.Run("default managed marker read", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("managed marker read error = %v", err) + } + }) + + t.Run("missing MCP URL cleans marker", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.InternationalMCPBaseURL), nil + } + return nil, os.ErrNotExist + }) + testseam.Swap(t, &authRemove, func(string) error { return nil }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatalf("missing MCP URL cleanup error = %v", err) + } + }) + + t.Run("MCP URL read", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.InternationalMCPBaseURL), nil + } + return nil, fail + }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("MCP URL read error = %v", err) + } + }) + + t.Run("default URL write", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("default URL write error = %v", err) + } + }) + + t.Run("final marker cleanup", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil }) + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("final marker cleanup error = %v", err) + } + }) + + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil { + t.Fatal("unsupported MCP persistence mode succeeded") + } +} + +func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) { + tests := []struct { + name string + raw string + wantURL string + wantErr string + }{ + {name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"}, + {name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"}, + {name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"}, + {name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"}, + {name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"}, + {name: "empty", raw: " ", wantErr: "cannot be empty"}, + {name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"}, + {name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"}, + {name: "missing host", raw: "https:///path", wantErr: "must include a host"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url") + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeAuthLoginBaseURL error = %v", err) + } + if got != tc.wantURL { + t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL) + } + }) + } +} + func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) { if !authLoginForcesAuthorization(authLoginConfig{}) { t.Fatal("auth login should force authorization by default so each login can add an organization profile") diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index db7e8f86..6f93ea55 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -36,6 +36,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" tea "github.com/charmbracelet/bubbletea" @@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) { func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) { oldEdition := edition.Get() t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) + for _, tc := range []struct { + raw string + region authpkg.LoginRegion + want string + }{ + {raw: "%", want: "%"}, + {raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"}, + {raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"}, + } { + if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want { + t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want) + } + } + if hasDirectRuntimeEndpointOverride("") { + t.Fatal("blank product unexpectedly has an endpoint override") + } + t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test") + if !hasDirectRuntimeEndpointOverride("coverage-product") { + t.Fatal("configured product endpoint override was not detected") + } + if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" { + t.Fatalf("invalid gateway base rewrote endpoint to %q", got) + } server := mcptypes.ServerDescriptor{ Endpoint: "https://one.test", CLI: mcptypes.CLIOverlay{ @@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) { } func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) oldInteractive := authLoginInteractiveTerminal authLoginInteractiveTerminal = func() bool { return false } t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") }) - for _, format := range []string{"table", "json"} { - t.Run(format, func(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + for _, tc := range []struct { + format string + international bool + }{ + {format: "table"}, + {format: "json", international: true}, + } { + t.Run(tc.format, func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) root := &cobra.Command{Use: "dws"} root.PersistentFlags().String("format", "table", "") root.PersistentFlags().Bool("yes", false, "") @@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { root.SetOut(&output) root.SetErr(io.Discard) args := []string{"login", "--token", "manual-token", "--yes"} - if format == "json" { + if tc.international { + args = append(args, "--intl") + } + if tc.format == "json" { args = append(args, "--format", "json") } root.SetArgs(args) if err := root.Execute(); err != nil || output.Len() == 0 { t.Fatalf("token login = %q %v", output.String(), err) } + data, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData error = %v", err) + } + wantRegion := "" + if tc.international { + wantRegion = string(authpkg.LoginRegionInternational) + } + if data.LoginRegion != wantRegion { + t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion) + } + if tc.international { + snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "") + if err != nil { + t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err) + } + gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion( + "https://mcp-gw.dingtalk.com/server/contact", + snapshot.LoginRegion, + ) + if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint { + t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint) + } + } }) } + t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + runLogin := func(international bool) { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "table", "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().String("profile", "", "") + root.AddCommand(newAuthLoginCommand(nil)) + args := []string{"login", "--token", "manual-token", "--yes"} + if international { + args = append(args, "--intl") + } + root.SetArgs(args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Fatalf("international=%v login error = %v", international, err) + } + } + + runLogin(true) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL { + t.Fatalf("international mcp_url = %q, %v", string(data), err) + } + runLogin(false) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL { + t.Fatalf("domestic mcp_url = %q, %v", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("managed MCP region marker remains: %v", err) + } + + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + runLogin(true) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("explicit mcp_url acquired a managed marker: %v", err) + } + }) + for _, hidden := range []bool{false, true} { old := edition.Get() edition.Override(&edition.Hooks{HideAuthLogin: hidden}) diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index 1597d6bd..6bec3b11 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string { func defaultPATGatewayBaseURL() string { raw := strings.TrimSpace(authpkg.GetMCPBaseURL()) + return mcpGatewayBaseURL(raw) +} + +func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string { + raw := strings.TrimSpace(authpkg.GetMCPBaseURL()) + if override := authpkg.MCPBaseURLOverride(); override != "" { + raw = override + } else { + raw = mcpBaseURLForLoginRegion(raw, region) + } + return mcpGatewayBaseURL(raw) +} + +func mcpGatewayBaseURL(raw string) string { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return strings.TrimRight(raw, "/") @@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string { return strings.TrimRight(parsed.String(), "/") } +func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return raw + } + host := strings.ToLower(parsed.Hostname()) + fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com" + if region.IsInternational() { + fromSuffix, toSuffix = toSuffix, fromSuffix + } + bareFrom := strings.TrimPrefix(fromSuffix, ".") + if host != bareFrom && !strings.HasSuffix(host, fromSuffix) { + return raw + } + if host == bareFrom { + host = strings.TrimPrefix(toSuffix, ".") + } else { + host = strings.TrimSuffix(host, fromSuffix) + toSuffix + } + if port := parsed.Port(); port != "" { + parsed.Host = net.JoinHostPort(host, port) + } else { + parsed.Host = host + } + return parsed.String() +} + // SetDynamicServers injects server data discovered from servers.json. // All product endpoints are resolved dynamically from this data. func SetDynamicServers(servers []mcptypes.ServerDescriptor) { @@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin return } id := strings.TrimSpace(server.CLI.ID) - endpoint := strings.TrimSpace(server.Endpoint) + endpoint := activeDingTalkGatewayEndpoint(server.Endpoint) if id != "" && endpoint != "" { endpoints[id] = endpoint products[id] = true @@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) { return "", false } +func hasDirectRuntimeEndpointOverride(productID string) bool { + normalized := normalizeDirectRuntimeProductID(productID) + for _, candidate := range []string{strings.TrimSpace(productID), normalized} { + if candidate == "" { + continue + } + if _, ok := productEndpointOverride(candidate); ok { + return true + } + } + return false +} + func editionServerEndpoint(productID string) (string, bool) { productID = strings.TrimSpace(productID) if productID == "" { @@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo return "", false } for _, server := range fn() { - endpoint := strings.TrimSpace(server.Endpoint) + endpoint := activeDingTalkGatewayEndpoint(server.Endpoint) if endpoint == "" { continue } @@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo return "", false } +func activeDingTalkGatewayEndpoint(endpoint string) string { + return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL()) +} + +func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string { + return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region)) +} + +func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string { + endpoint = strings.TrimSpace(endpoint) + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return endpoint + } + if !isDingTalkMCPGatewayHost(parsed.Hostname()) { + return endpoint + } + base, err := url.Parse(gatewayBaseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return endpoint + } + parsed.Scheme = base.Scheme + parsed.Host = base.Host + return strings.TrimRight(parsed.String(), "/") +} + +func isDingTalkMCPGatewayHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io": + return true + default: + return false + } +} + +func isDingTalkMCPGatewayEndpoint(endpoint string) bool { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname()) +} + // DirectRuntimeProductIDs returns product IDs that should stay visible for // direct runtime execution. Dynamic products come from MCP discovery/plugin // registration; built-in helper products such as devapp resolve their endpoint diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index a3eeaf51..4717d2b1 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -479,7 +479,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo if err != nil { t.Fatalf("alias execution failed: %v", err) } - if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" { + if alias == "open-conversation-id" { + if ctx == nil || len(ctx.Corrections) != 0 { + t.Fatalf("alias corrections = %#v", ctx) + } + } else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" { t.Fatalf("alias corrections = %#v", ctx) } if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 2224ad22..09fc432e 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -93,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { }{ {args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, {args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, - {args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, - {args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, + {args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id "}, + {args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id "}, } { command := NewRootCommand() command.SilenceErrors = true @@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) { } } +func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) { + previous := edition.Get() + defer edition.Override(previous) + defer SetDynamicServers(nil) + + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + edition.Override(&edition.Hooks{ + Name: "test", + StaticServers: func() []edition.ServerInfo { + return []edition.ServerInfo{{ + ID: "contact", + Name: "Contact", + Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc", + Prefixes: []string{"user"}, + }} + }, + }) + + injectStaticServers() + + for _, productID := range []string{"contact", "user"} { + got, ok := directRuntimeEndpoint(productID, "") + want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc" + if !ok || got != want { + t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want) + } + } +} + +func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + mcpURLPath := filepath.Join(configDir, "mcp_url") + + if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc" + if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want { + t.Fatalf("domestic profile endpoint = %q, want %q", got, want) + } + if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint { + t.Fatalf("international profile endpoint = %q, want %q", got, endpoint) + } + + if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want { + t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io") + defer restore() + + got := activeDingTalkGatewayEndpointForLoginRegion( + "https://mcp-gw.dingtalk.com/server/contact", + authpkg.LoginRegionDefault, + ) + want := "https://pre-mcp-gw.dingtalk.io/server/contact" + if got != want { + t.Fatalf("login-scoped endpoint = %q, want %q", got, want) + } +} + func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { t.Helper() cmd := root diff --git a/internal/app/runner.go b/internal/app/runner.go index 175f70b9..36e78c1f 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -162,7 +162,7 @@ var ( runnerResolveMultiProfileSelections = resolveMultiProfileSelections runnerResolveProfile = authpkg.ResolveProfile runnerGetCachedRuntimeToken = getCachedRuntimeToken - runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken + runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerCallTool = (*transport.Client).CallTool runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized @@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if hasPluginAuth { authToken = pluginAuth.Token } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { - var tokenErr error - authToken, tokenErr = runnerResolveAuthToken(r, ctx) + snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx) if tokenErr != nil { return executor.Result{}, tokenResolutionError(tokenErr) } + authToken = snapshot.AccessToken + if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) && + isDingTalkMCPGatewayEndpoint(endpoint) && + (snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") { + endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion) + } } var timeoutSec int @@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint( } func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { - explicitToken := "" - if r != nil && r.globalFlags != nil { - explicitToken = r.globalFlags.Token - } - return resolveRuntimeAuthToken(ctx, explicitToken) -} - -func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { - snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) + snapshot, err := r.resolveAuthSnapshot(ctx) if err != nil { return "", err } return snapshot.AccessToken, nil } +func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) { + explicitToken := "" + if r != nil && r.globalFlags != nil { + explicitToken = r.globalFlags.Token + } + return resolveRuntimeAuthSnapshot(ctx, explicitToken) +} + +func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { + snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken) + if err != nil { + return "", err + } + return snapshot.AccessToken, nil +} + +func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) { + return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) +} + // getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The // cache itself lives exclusively in TokenManager. func getCachedRuntimeToken(ctx context.Context) (string, error) { diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 34e1b0ba..28de556c 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) { } func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldEdition := edition.Get() oldPreflight := runnerPreflightDocDownload oldCall := runnerCallTool oldHandle := runnerHandlePatAuthCheck oldRetry := runnerRetryWithPatAuthRetry oldCapture := runnerCaptureRuntimeFailure + oldResolveSnapshot := runnerResolveAuthSnapshot t.Cleanup(func() { edition.Override(oldEdition) runnerPreflightDocDownload = oldPreflight @@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { runnerHandlePatAuthCheck = oldHandle runnerRetryWithPatAuthRetry = oldRetry runnerCaptureRuntimeFailure = oldCapture + runnerResolveAuthSnapshot = oldResolveSnapshot }) pluginAuthMu.Lock() @@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented { t.Fatalf("default auth execution = %#v, %v", got, err) } + runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) { + return AccessTokenSnapshot{ + AccessToken: "international-token", + LoginRegion: authpkg.LoginRegionInternational, + LoginRegionKnown: true, + }, nil + } + successfulCall := runnerCallTool + routedEndpoint := "" + runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) { + routedEndpoint = endpoint + return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil { + t.Fatalf("international auth execution = %v", err) + } + if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" { + t.Fatalf("international routed endpoint = %q", routedEndpoint) + } + runnerResolveAuthSnapshot = oldResolveSnapshot + runnerCallTool = successfulCall wantErr := errors.New("preflight") runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { @@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { t.Fatalf("runtime explicit token = %q, %v", got, err) } + edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) { + return "", errors.New("snapshot failed") + }}) + if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") { + t.Fatalf("resolveAuthToken error = %v", err) + } t.Setenv(envDWSChannel, "channel") edition.Override(&edition.Hooks{ diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 0a8626a8..55950aa0 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -207,7 +207,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) { }) auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation") + assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true) assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"}) + if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil { + t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"]) + } } func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) { diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index d64d9ee8..bc4909bd 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) { } } +func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) { + authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational) + if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") { + t.Fatalf("auth URL = %s, want international authorize host", authURL) + } +} + +func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { + if !strings.Contains(notEnabledHTML, "status.authorizeUrl") { + t.Fatal("not-enabled page must read the authorize URL from the regional login status") + } + if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) { + t.Fatal("not-enabled page must not hard-code the domestic authorize URL") + } +} + +func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) { + if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL { + t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL) + } + if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL { + t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL) + } + if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL { + t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL) + } + if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL { + t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL) + } + if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL { + t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL) + } +} + +func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) { + var nilProvider *OAuthProvider + if got := nilProvider.loginRegion(); got != LoginRegionDefault { + t.Fatalf("nil provider login region = %q", got) + } + nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + nilProvider.applyLoginRegionToToken(&TokenData{}) + + provider := &OAuthProvider{} + provider.useTokenLoginRegion(nil) + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + if provider.LoginRegion != LoginRegionInternational { + t.Fatalf("provider login region = %q, want international", provider.LoginRegion) + } + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)}) + provider.applyLoginRegionToToken(nil) + token := &TokenData{} + provider.applyLoginRegionToToken(token) + if token.LoginRegion != string(LoginRegionInternational) { + t.Fatalf("token login region = %q, want international", token.LoginRegion) + } +} + +func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { + restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/") + defer restore() + + if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" { + t.Fatalf("international MCP base URL = %q, want override", got) + } +} + +func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { + restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/") + defer restore() + + if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" { + t.Fatalf("international device base URL = %q, want override", got) + } + if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" { + t.Fatalf("international authorize URL = %q, want override", got) + } + if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" { + t.Fatalf("international user access token URL = %q, want override", got) + } +} + func buildTokenDataFromResponse(resp tokenResponse) *TokenData { if resp.AccessToken == "" { return nil diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index 6ed68644..802035ea 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -14,10 +14,12 @@ package auth import ( + "bytes" "context" "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/http/httptest" "os" @@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) } +func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) { + t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "") + + var logBuf bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(previousLogger) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" { + t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got) + } + if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" { + t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got) + } + w.Header().Set("EagleEye-TraceId", "server-trace-001") + w.Header().Set("EagleEye-RpcId", "rpc-001") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{CLIAuthEnabled: true}, + }) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.Result == nil || !status.Result.CLIAuthEnabled { + t.Fatal("expected CLIAuthEnabled=true") + } + + logs := logBuf.String() + for _, want := range []string{ + "auth.cli_auth_enabled.request", + "auth.cli_auth_enabled.response", + "trace-for-cli-auth-test", + "server-trace-001", + "rpc-001", + } { + if !strings.Contains(logs, want) { + t.Fatalf("debug logs missing %q, got: %s", want, logs) + } + } + if strings.Contains(logs, "sensitive-token") { + t.Fatalf("debug logs leaked access token: %s", logs) + } +} + +func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case ClientIDPath: + _ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"}) + case SuperAdminPath: + _ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}}) + case SendCliAuthApplyPath: + _ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldClient := oauthHTTPClient + oauthHTTPClient = server.Client() + restoreBaseURL := PushMCPBaseURLOverride(server.URL) + t.Cleanup(func() { + restoreBaseURL() + oauthHTTPClient = oldClient + }) + + ctx := context.Background() + for name, fetch := range map[string]func() (string, error){ + "device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + "oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + } { + if got, err := fetch(); err != nil || got != "international-client" { + t.Fatalf("%s client ID = %q, %v", name, got, err) + } + } + if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("device admins = %#v, %v", got, err) + } + if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth admins = %#v, %v", got, err) + } + if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth apply = %#v, %v", got, err) + } +} + func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/auth/coverage_edges_test.go b/internal/auth/coverage_edges_test.go index d850988a..4fa0a166 100644 --- a/internal/auth/coverage_edges_test.go +++ b/internal/auth/coverage_edges_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) { if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil { t.Fatal(err) } - for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} { + for name, value := range map[string]string{ + "app.json": "{}", + profilesJSONFile: "{}", + "mcp_url": "https://mcp.test", + config.ManagedMCPURLRegionFileName: "https://mcp.test", + "terminal_url": "https://terminal.test", + } { if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil { t.Fatal(err) } @@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil { t.Fatal("config file was not imported") } + if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil { + t.Fatal("managed MCP region marker was not imported") + } if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil { t.Fatal("encrypted token was not imported") } diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index c711949a..a990072d 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -74,6 +74,20 @@ var ( } ) +func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + if region.IsInternational() { + return FetchClientIDFromMCPForLoginRegion(ctx, region) + } + return deviceFetchClientID(ctx) +} + +func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + if region.IsInternational() { + return GetSuperAdminsForLoginRegion(ctx, accessToken, region) + } + return deviceGetAdmins(ctx, accessToken) +} + type DeviceFlowProvider struct { configDir string clientID string @@ -85,6 +99,7 @@ type DeviceFlowProvider struct { httpClient *http.Client NoBrowser bool IdentityEnricher func(context.Context, *TokenData) error + LoginRegion LoginRegion } func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider { @@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) { p.baseURL = strings.TrimRight(baseURL, "/") } +func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) { + p.LoginRegion = region + p.baseURL = DeviceBaseURLForLoginRegion(region) + p.terminalBaseURL = MCPBaseURLForLoginRegion(region) +} + // SetTerminalBaseURL sets the terminal API base URL for device flow polling. func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) { p.terminalBaseURL = strings.TrimRight(baseURL, "/") @@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { if p.logger != nil { p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") } - mcpClientID, mcpErr := deviceFetchClientID(ctx) + mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion) if mcpErr != nil { return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) } @@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token clientID: p.clientID, logger: p.logger, IdentityEnricher: p.IdentityEnricher, + LoginRegion: p.LoginRegion, } tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode) if err != nil { @@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) _, _ = fmt.Fprintln(p.output(), "") - admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken) + admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion) if adminErr == nil && admins.Success && len(admins.Result) > 0 { maxAdmins := 3 if len(admins.Result) < maxAdmins { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 0abc4730..df517041 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) { } } +func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) { + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + provider.SetLoginRegion(LoginRegionInternational) + if provider.baseURL != InternationalDeviceBaseURL { + t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL) + } + if provider.terminalBaseURL != InternationalMCPBaseURL { + t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL) + } +} + func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) { t.Parallel() diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 098297f2..31a204cc 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -50,9 +50,15 @@ const ( // AuthorizeURL is the DingTalk OAuth authorization page. AuthorizeURL = "https://login.dingtalk.com/oauth2/auth" + // InternationalAuthorizeURL is the DingTalk international OAuth authorization page. + InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth" + // UserAccessTokenURL exchanges an authorization code for user tokens. UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" + // InternationalUserAccessTokenURL exchanges authorization codes for international user tokens. + InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken" + // UserInfoURL fetches the authenticated user's profile. UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me" @@ -75,6 +81,9 @@ const ( // DefaultDeviceBaseURL is the login server base URL for device flow. DefaultDeviceBaseURL = "https://login.dingtalk.com" + // InternationalDeviceBaseURL is the international login server base URL for device flow. + InternationalDeviceBaseURL = "https://login.dingtalk.io" + // DeviceCodePath requests a device_code and user_code. DeviceCodePath = "/oauth2/device/code.json" @@ -96,11 +105,12 @@ const ( LogoutContinueURL = "https://login.dingtalk.com" // MCP API endpoints for CLI authorization management. - DefaultMCPBaseURL = config.DefaultMCPBaseURL - CLIAuthEnabledPath = "/cli/cliAuthEnabled" - SuperAdminPath = "/cli/superAdmin" - SendCliAuthApplyPath = "/cli/sendCliAuthApply" - ClientIDPath = "/cli/clientId" + DefaultMCPBaseURL = config.DefaultMCPBaseURL + InternationalMCPBaseURL = "https://mcp.dingtalk.io" + CLIAuthEnabledPath = "/cli/cliAuthEnabled" + SuperAdminPath = "/cli/superAdmin" + SendCliAuthApplyPath = "/cli/sendCliAuthApply" + ClientIDPath = "/cli/clientId" // MCP OAuth endpoints (used when clientId is fetched from MCP). MCPOAuthTokenPath = "/oauth2/getToken" @@ -114,6 +124,57 @@ const ( AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" ) +type LoginRegion string + +const ( + LoginRegionDefault LoginRegion = "" + LoginRegionInternational LoginRegion = "international" +) + +func (r LoginRegion) IsInternational() bool { + return r == LoginRegionInternational +} + +func AuthorizeURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + "/oauth2/auth" + } + if region.IsInternational() { + return InternationalAuthorizeURL + } + return AuthorizeURL +} + +func DeviceBaseURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + } + if region.IsInternational() { + return InternationalDeviceBaseURL + } + return DefaultDeviceBaseURL +} + +func MCPBaseURLForLoginRegion(region LoginRegion) string { + if override := MCPBaseURLOverride(); override != "" { + return override + } + if region.IsInternational() { + return InternationalMCPBaseURL + } + return GetMCPBaseURL() +} + +func UserAccessTokenURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + "/v1.0/oauth2/userAccessToken" + } + if region.IsInternational() { + return InternationalUserAccessTokenURL + } + return UserAccessTokenURL +} + // GetTerminalBaseURL returns the terminal base URL with priority: // 1. ~/.dws/terminal_url file content (for pre-release environment) // 2. Default value (https://open-dev.dingtalk.com) @@ -146,8 +207,54 @@ var ( // clientIDFromMCP indicates whether the clientID was fetched from MCP server. // When true, MCP OAuth endpoints should be used instead of direct DingTalk API. clientIDFromMCP bool + + loginBaseURLMu sync.RWMutex + loginBaseURLOverride string + mcpBaseURLMu sync.RWMutex + mcpBaseURLOverride string ) +// PushLoginBaseURLOverride sets a process-local DingTalk login base URL +// override and returns a restore function. +func PushLoginBaseURLOverride(baseURL string) func() { + loginBaseURLMu.Lock() + previous := loginBaseURLOverride + loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/") + loginBaseURLMu.Unlock() + return func() { + loginBaseURLMu.Lock() + loginBaseURLOverride = previous + loginBaseURLMu.Unlock() + } +} + +func LoginBaseURLOverride() string { + loginBaseURLMu.RLock() + defer loginBaseURLMu.RUnlock() + return loginBaseURLOverride +} + +// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns +// a restore function. It is intended for one command invocation, such as +// pre-release smoke testing. +func PushMCPBaseURLOverride(baseURL string) func() { + mcpBaseURLMu.Lock() + previous := mcpBaseURLOverride + mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/") + mcpBaseURLMu.Unlock() + return func() { + mcpBaseURLMu.Lock() + mcpBaseURLOverride = previous + mcpBaseURLMu.Unlock() + } +} + +func MCPBaseURLOverride() string { + mcpBaseURLMu.RLock() + defer mcpBaseURLMu.RUnlock() + return mcpBaseURLOverride +} + // SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced. func SetClientIDFromMCP(id string) { clientMu.Lock() diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index cc30a4a3..fe2c4728 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -20,6 +20,7 @@ import ( "fmt" "html" "io" + "log/slog" "net/http" "net/url" "os" @@ -28,6 +29,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/google/uuid" ) var ( @@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa "code": code, "grantType": "authorization_code", } - resp, err := p.postJSON(ctx, UserAccessTokenURL, body) + resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body) if err != nil { return nil, err } @@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = resolveCredentialSource() + p.applyLoginRegionToToken(data) // Save clientSecret for future refresh (even if env changes) if err := oauthSaveClientSecret(clientID, clientSecret); err != nil { // Log warning but don't fail login @@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa return data, nil } +func (p *OAuthProvider) loginRegion() LoginRegion { + if p == nil { + return LoginRegionDefault + } + return p.LoginRegion +} + +func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) { + if p == nil || p.LoginRegion != LoginRegionDefault || data == nil { + return + } + if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault { + p.LoginRegion = region + } +} + +func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) { + if data == nil { + return + } + if region := p.loginRegion(); region != LoginRegionDefault { + data.LoginRegion = string(region) + } +} + // exchangeCodeViaMCP exchanges auth code for token via MCP proxy. // This is used when client secret is not available (server-side secret management). func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { clientID := ClientID() - url := GetMCPBaseURL() + MCPOAuthTokenPath + url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath body := map[string]string{ "clientId": clientID, "authCode": code, @@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = "mcp" + p.applyLoginRegionToToken(data) // MCP mode doesn't need to save clientSecret (server-side managed) return data, nil } @@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token // Use stored Source to determine refresh path (not current runtime state) // This ensures refresh works even if environment variables changed since login if data.Source == "mcp" { + p.useTokenLoginRegion(data) return p.refreshViaMCP(ctx, data) } + p.useTokenLoginRegion(data) // Direct mode: use stored clientId and load saved clientSecret clientID := data.ClientID @@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token "refreshToken": data.RefreshToken, "grantType": "refresh_token", } - resp, err := p.postJSON(ctx, UserAccessTokenURL, body) + resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body) if err != nil { return nil, err } @@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source + updated.LoginRegion = data.LoginRegion updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID @@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录") } - url := GetMCPBaseURL() + MCPRefreshTokenPath + url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath body := map[string]string{ "clientId": clientID, "refreshToken": data.RefreshToken, @@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source + updated.LoginRegion = data.LoginRegion updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID @@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string { } func buildAuthURL(clientID, redirectURI, targetCorpID string) string { + return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault) +} + +func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, @@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string { if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" { params.Set("corpId", targetCorpID) } - return AuthorizeURL + "?" + params.Encode() + return AuthorizeURLForLoginRegion(region) + "?" + params.Encode() } const successHTML = ` @@ -937,14 +974,15 @@ const notEnabledHTML = ` clientId = status.clientId || ""; applySent = status.applySent || false; selectedAdminId = status.selectedAdminId || ""; + const authorizeUrl = status.authorizeUrl || ""; - if (clientId) { + if (clientId && authorizeUrl) { const port = location.port; const redirectUri = encodeURIComponent( "http://127.0.0.1:" + port + "/callback" ); backLink.href = - "https://login.dingtalk.com/oauth2/auth?client_id=" + + authorizeUrl + "?client_id=" + clientId + "&prompt=consent&redirect_uri=" + redirectUri + @@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3 // false negatives caused by momentary network issues. func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { var lastErr error + traceID := cliAuthTraceID() for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { @@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) + status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID) if err == nil { return status, nil } @@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str } func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { - url := GetMCPBaseURL() + CLIAuthEnabledPath + return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID()) +} + +func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) { + url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + applyCLIAuthTraceHeaders(req, traceID) if ch := os.Getenv("DWS_CHANNEL"); ch != "" { req.Header.Set("x-dws-channel", ch) } applyEditionEnterpriseCredentialHeaders(req) + slog.Debug("auth.cli_auth_enabled.request", + "attempt", attempt, + "url", url, + "trace_id", traceID, + "channel", os.Getenv("DWS_CHANNEL"), + ) client := p.httpClient if client == nil { @@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s } resp, err := client.Do(req) if err != nil { + slog.Debug("auth.cli_auth_enabled.error", + "attempt", attempt, + "url", url, + "trace_id", traceID, + "error", err, + ) return nil, fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() + slog.Debug("auth.cli_auth_enabled.response", + "attempt", attempt, + "url", url, + "status", resp.StatusCode, + "trace_id", traceID, + "response_trace_id", cliAuthResponseTraceID(resp.Header), + "eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"), + ) data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) if err != nil { @@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s return &status, nil } +func cliAuthTraceID() string { + if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" { + return traceID + } + return strings.ReplaceAll(uuid.NewString(), "-", "") +} + +func applyCLIAuthTraceHeaders(req *http.Request, traceID string) { + if req == nil || traceID == "" { + return + } + req.Header.Set("EagleEye-TraceId", traceID) + req.Header.Set("X-Dingtalk-Trace-Id", traceID) +} + +func cliAuthResponseTraceID(headers http.Header) string { + for _, key := range []string{ + "EagleEye-TraceId", + "X-Trace-Id", + "X-Request-Id", + "X-Dingtalk-Trace-Id", + } { + if value := strings.TrimSpace(headers.Get(key)); value != "" { + return value + } + } + return "" +} + // GetSuperAdmins fetches the list of corp super admins. // It retries up to mcpRequestMaxRetries times on transient errors. func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault) +} + +func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - result, err := doGetSuperAdmins(ctx, accessToken) + result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region) if err == nil { return result, nil } @@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons } func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { - url := GetMCPBaseURL() + SuperAdminPath + return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault) +} + +func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + url := MCPBaseURLForLoginRegion(region) + SuperAdminPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) @@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo // SendCliAuthApply sends a CLI auth apply request to the specified admin. // It retries up to mcpRequestMaxRetries times on transient errors. func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault) +} + +func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) + result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region) if err == nil { return result, nil } @@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S } func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { - url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID + return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault) +} + +func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { + url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) @@ -1557,6 +1666,10 @@ type ClientIDResponse struct { // This is used when no client ID is provided via flags, config, or env vars. // It retries up to mcpRequestMaxRetries times on transient errors. func FetchClientIDFromMCP(ctx context.Context) (string, error) { + return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault) +} + +func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) { case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - id, err := doFetchClientIDFromMCP(ctx) + id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region) if err == nil { return id, nil } @@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) { } func doFetchClientIDFromMCP(ctx context.Context) (string, error) { - url := GetMCPBaseURL() + ClientIDPath + return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault) +} + +func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + url := MCPBaseURLForLoginRegion(region) + ClientIDPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 2396f650..da6b66df 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -68,6 +68,27 @@ var ( oauthSleep = time.Sleep ) +func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + if region.IsInternational() { + return FetchClientIDFromMCPForLoginRegion(ctx, region) + } + return oauthFetchClientID(ctx) +} + +func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + if region.IsInternational() { + return GetSuperAdminsForLoginRegion(ctx, accessToken, region) + } + return oauthGetAdmins(ctx, accessToken) +} + +func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { + if region.IsInternational() { + return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region) + } + return oauthSendApply(ctx, accessToken, adminStaffID) +} + // OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow. type OAuthProvider struct { configDir string @@ -80,6 +101,7 @@ type OAuthProvider struct { // IdentityEnricher resolves userId/userName/corpName while the freshly // exchanged access token is still only in memory. IdentityEnricher func(context.Context, *TokenData) error + LoginRegion LoginRegion } // NewOAuthProvider creates a new OAuth provider. @@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro if p.logger != nil { p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") } - mcpClientID, mcpErr := oauthFetchClientID(ctx) + mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion) if mcpErr != nil { return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) } @@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } - result, err := oauthGetAdmins(ctx, token.AccessToken) + result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return @@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } - result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID) + result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return @@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro applySent := callbackApplySent selectedAdminId := callbackSelectedAdminId callbackTokenMu.Unlock() - _, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId) + data, _ := json.Marshal(map[string]any{ + "clientId": p.clientID, + "authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion), + "applySent": applySent, + "selectedAdminId": selectedAdminId, + }) + _, _ = w.Write(data) }) // API endpoint: check CLI auth enabled status @@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _ = server.Shutdown(shutCtx) }() - authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID) + authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion) if p.logger != nil { p.logger.Debug("authorization URL", "url", authURL) } diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index 3be3f85a..420c9634 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo func portableConfigFiles(configDir string) ([]string, error) { var files []string - patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"} + patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"} for _, pattern := range patterns { matches, err := portableGlob(filepath.Join(configDir, pattern)) if err != nil { diff --git a/internal/auth/token.go b/internal/auth/token.go index b4d02294..c511b45b 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -97,6 +97,7 @@ type TokenData struct { ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh UpdatedAt string `json:"updated_at,omitempty"` Source string `json:"source,omitempty"` + LoginRegion string `json:"login_region,omitempty"` // LegacyOrgScopedProfile is an in-memory destination for an explicitly // matched historical profile whose userId was never resolved. It is never // persisted as token material. diff --git a/internal/cli/param_aliases.go b/internal/cli/param_aliases.go index 930a3762..2f3ed4e5 100644 --- a/internal/cli/param_aliases.go +++ b/internal/cli/param_aliases.go @@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc aliasMap := make(map[string]string) blockedSet := make(map[string]bool) excludedSet := make(map[string]bool) + claimedRealSet := make(map[string]bool) pendingReview := ov.Confirm || ov.Investigate for boundFlag, conceptID := range ov.Bind { @@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc if len(candidates) == 0 { continue } + for m := range eff { + if _, isReal := realByMorph[m]; isReal { + claimedRealSet[m] = true + } + } + for _, exclude := range concept.Excludes { + excludedSet[cmdutil.Morph(exclude)] = true + } visible := distinctRealNames(candidates, true) var canon string switch len(visible) { @@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc } aliasMap[m] = canon } - // Excludes are not passive prose: once this concept is active on a - // reviewed command, a non-real excluded spelling is protected from - // downstream fuzzy correction. A real flag is left alone because it - // already has an independently valid command-local meaning. - for _, exclude := range concept.Excludes { - morphed := cmdutil.Morph(exclude) - if _, isReal := realByMorph[morphed]; !isReal { - excludedSet[morphed] = true - } - } } for excluded := range excludedSet { - if _, isAlias := aliasMap[excluded]; !isAlias { - blockedSet[excluded] = true + if _, isAlias := aliasMap[excluded]; isAlias { + continue } + if claimedRealSet[excluded] { + continue + } + if _, isReal := realByMorph[excluded]; isReal { + continue + } + blockedSet[excluded] = true } // (b) Command scoped aliases override concept reductions. diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index dc98598e..c7c33ae3 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"cursor"}, }, { - CLIPath: "chat category add-conv", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + CLIPath: "chat category add-conv", + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat", "chat-id", "open-conversation-id"}, }, { CLIPath: "chat category create", @@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"category-ids"}, }, { - CLIPath: "chat category remove-conv", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + CLIPath: "chat category remove-conv", + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat", "chat-id", "open-conversation-id"}, }, { CLIPath: "chat category rename", @@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat conversation-info", Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", - "staff-id": "user", - "uid": "user", - "userid": "user", + "staff-id": "user", + "uid": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat group audit-join-validation", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"}, }, { @@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat group dismiss", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group get-by-group-id", @@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat group invite-url", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group members", @@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat group notice create", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group notice edit", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group notice get", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group notice list", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group quit", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group rename", @@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat group set-admin", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - "user-ids": "users", + "chat-id": "conversation-id", + "user-ids": "users", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, }, { CLIPath: "chat group set-history", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group share-invite", @@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat group transfer-owner", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - "staff-id": "user", - "uid": "user", - "userid": "user", + "chat-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group update-alias", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, - }, - { - CLIPath: "chat group update-icon", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, - }, - { - CLIPath: "chat group update-nick", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, - }, - { - CLIPath: "chat group update-settings", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, - }, - { - CLIPath: "chat group-mute", - Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, + { + CLIPath: "chat group update-icon", + Aliases: map[string]string{ + "chat-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-nick", + Aliases: map[string]string{ + "chat-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-settings", + Aliases: map[string]string{ + "chat-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-mute", + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, + }, { CLIPath: "chat group-mute-member", Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", - "user-ids": "users", + "user-ids": "users", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat group-role add", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group-role list", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group-role query-user", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - "staff-id": "user", - "uid": "user", - "userid": "user", + "chat-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role remove", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group-role remove-user", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - "staff-id": "user", - "uid": "user", - "userid": "user", + "chat-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role set-user", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - "staff-id": "user", - "uid": "user", - "userid": "user", + "chat-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role update", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat hide", @@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message add-emoji", Aliases: map[string]string{ - "chat-id": "conversation-id", - "message-id": "msg-id", - "open-conversation-id": "conversation-id", - "open-message-id": "msg-id", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"open-message-id"}, }, { CLIPath: "chat message add-favorite", @@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message add-text-emotion", Aliases: map[string]string{ - "chat-id": "conversation-id", - "message-id": "msg-id", - "open-conversation-id": "conversation-id", - "open-message-id": "msg-id", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"open-message-id"}, }, { CLIPath: "chat message combine-forward", @@ -1427,8 +1344,6 @@ var generatedParamAliases = []ParamAliasEntry{ Aliases: map[string]string{ "dest-open-cid": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", - "message-id": "msg-id", - "open-message-id": "msg-id", "source-conversation-id": "src-conversation-id", "src-open-cid": "src-conversation-id", "target-conversation-id": "dest-conversation-id", @@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message list", Aliases: map[string]string{ - "chat-id": "group", - "max-result": "limit", - "max-results": "limit", - "open-conversation-id": "group", - "page-size": "limit", - "per-page": "limit", - "staff-id": "user", - "start": "time", - "take": "limit", - "top": "limit", - "uid": "user", - "user-id": "user", - "userid": "user", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "staff-id": "user", + "start": "time", + "take": "limit", + "top": "limit", + "uid": "user", + "user-id": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat message list-all", @@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{ }, }, { - CLIPath: "chat message list-mentions", - Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + CLIPath: "chat message list-mentions", + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat message list-pin-msg", @@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message list-topic-replies", Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", + "chat-id": "conversation-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, @@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message read-status", Aliases: map[string]string{ - "chat-id": "conversation-id", - "msg-id": "message-id", - "open-conversation-id": "conversation-id", - "open-message-id": "message-id", - "user-ids": "users", + "chat-id": "conversation-id", + "msg-id": "message-id", + "open-message-id": "message-id", + "user-ids": "users", }, Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"}, }, @@ -1561,30 +1470,25 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "chat message recall", Aliases: map[string]string{ "chat-id": "conversation-id", - "message-id": "msg-id", "open-conversation-id": "conversation-id", - "open-message-id": "msg-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, { CLIPath: "chat message recall-by-bot", Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", - "robot": "robot-code", + "robot": "robot-code", }, - Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat message remove-emoji", Aliases: map[string]string{ - "chat-id": "conversation-id", - "message-id": "msg-id", - "open-conversation-id": "conversation-id", - "open-message-id": "msg-id", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"open-message-id"}, }, { CLIPath: "chat message remove-favorite", @@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message remove-text-emotion", Aliases: map[string]string{ - "chat-id": "conversation-id", - "message-id": "msg-id", - "open-conversation-id": "conversation-id", - "open-message-id": "msg-id", + "chat-id": "conversation-id", }, - Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"open-message-id"}, }, { CLIPath: "chat message reply", @@ -1620,12 +1522,9 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, }, { - CLIPath: "chat message search", - Aliases: map[string]string{ - "chat-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + CLIPath: "chat message search", + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat message search-advanced", @@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat message send", Aliases: map[string]string{ - "chat-id": "group", - "file": "file-path", - "open-conversation-id": "group", - "staff-id": "user", - "to-user": "user", - "uid": "user", - "user-id": "user", - "userid": "user", + "file": "file-path", + "staff-id": "user", + "to-user": "user", + "uid": "user", + "user-id": "user", + "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"}, + Ambiguous: []string{"chat-id", "open-conversation-id"}, }, { CLIPath: "chat message send-by-bot", Aliases: map[string]string{ - "at-users": "at-user-ids", - "chat-id": "group", - "open-conversation-id": "group", - "robot": "robot-code", - "user-ids": "users", + "at-users": "at-user-ids", + "robot": "robot-code", + "user-ids": "users", }, Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"}, - Ambiguous: []string{"at-ids"}, + Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"}, }, { CLIPath: "chat message send-by-webhook", @@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{ }, }, { - CLIPath: "chat message send-card", - Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", - }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + CLIPath: "chat message send-card", + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + Ambiguous: []string{"chat", "chat-id", "open-conversation-id"}, }, { CLIPath: "chat message set-pin-msg", @@ -1684,8 +1575,6 @@ var generatedParamAliases = []ParamAliasEntry{ "chat-id": "open-conversation-id", "conversation-id": "open-conversation-id", "group": "open-conversation-id", - "message-id": "msg-id", - "open-message-id": "msg-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, @@ -1696,8 +1585,6 @@ var generatedParamAliases = []ParamAliasEntry{ "chat-id": "open-conversation-id", "conversation-id": "open-conversation-id", "group": "open-conversation-id", - "message-id": "msg-id", - "open-message-id": "msg-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, @@ -1708,8 +1595,6 @@ var generatedParamAliases = []ParamAliasEntry{ "chat-id": "open-conversation-id", "conversation-id": "open-conversation-id", "group": "open-conversation-id", - "message-id": "msg-id", - "open-message-id": "msg-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, @@ -1720,17 +1605,13 @@ var generatedParamAliases = []ParamAliasEntry{ "chat-id": "open-conversation-id", "conversation-id": "open-conversation-id", "group": "open-conversation-id", - "message-id": "msg-id", - "open-message-id": "msg-id", }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, { CLIPath: "chat mute", Aliases: map[string]string{ - "chat-id": "conversation-id", - "group": "conversation-id", - "open-conversation-id": "conversation-id", + "chat-id": "conversation-id", }, Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"}, }, @@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"}, }, { diff --git a/internal/cli/param_aliases_test.go b/internal/cli/param_aliases_test.go index 64f76daa..843738f6 100644 --- a/internal/cli/param_aliases_test.go +++ b/internal/cli/param_aliases_test.go @@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) { }) } +func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}), + []Concept{ + {ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}}, + {ID: "query", Members: []string{"query", "keyword"}}, + }, + CommandOverride{Bind: map[string]string{"id": "base_id"}}, + ) + if len(problems) != 0 { + t.Fatalf("reduceLeafParamAliases() problems = %v", problems) + } + if entry == nil { + t.Fatal("reduceLeafParamAliases() entry = nil") + } + if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" { + t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases) + } + if entry.Aliases["keyword"] != "query" { + t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases) + } + if containsParamAlias(entry.Blocked, "keyword") { + t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked) + } + if containsParamAlias(entry.Blocked, "name") { + t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked) + } + if containsParamAlias(entry.Blocked, "query") { + t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked) + } + if !containsParamAlias(entry.Blocked, "unsafe") { + t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked) + } +} + func TestParamAliasEntryLookupMethods(t *testing.T) { entry := ParamAliasEntry{ Aliases: map[string]string{"uid": "user"}, @@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep } } +func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) { + concepts := []Concept{ + {ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}}, + } + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil { + t.Fatal("expected a reduced entry") + } + if containsParamAlias(entry.Blocked, "item-ids") { + t.Fatalf("real exclude was blocked: %#v", entry) + } +} + func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) { real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"}) for name, override := range map[string]CommandOverride{ @@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi } } +func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) { + entries := make(map[string]ParamAliasEntry, len(generatedParamAliases)) + for _, entry := range generatedParamAliases { + entries[entry.CLIPath] = entry + } + assertBlocked := func(path string, names ...string) { + t.Helper() + entry, ok := entries[path] + if !ok { + t.Fatalf("missing generated alias entry for %q", path) + } + for _, name := range names { + if !entry.IsBlocked(cmdutil.Morph(name)) { + t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry) + } + } + } + + for _, path := range []string{ + "chat message add-emoji", + "chat message remove-emoji", + "chat message add-text-emotion", + "chat message remove-text-emotion", + } { + assertBlocked(path, "msg-ids", "message-ids") + } + for _, path := range []string{ + "chat message send", + "chat conversation-info", + "chat category add-conv", + "chat category remove-conv", + "chat message list", + "chat message list-mentions", + "chat message recall-by-bot", + "chat message search", + } { + assertBlocked(path, "conversation-ids") + } +} + +func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) { + entry, ok := LookupParamAlias("chat group audit-join-validation") + if !ok { + t.Fatal("missing generated alias entry for chat group audit-join-validation") + } + for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} { + if !entry.IsAmbiguous(cmdutil.Morph(name)) { + t.Fatalf("%q not ambiguous; entry = %#v", name, entry) + } + } +} + // TestGeneratedParamAliasesAreWellFormed guards the committed generated table // at the Go level, complementing the byte-identity drift gate. func TestGeneratedParamAliasesAreWellFormed(t *testing.T) { diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index eb8f5d17..f8297b32 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -60,11 +60,17 @@ "chat group members": {"bind": {"id": "open_conversation_id"}}, "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, "chat group members remove": {"bind": {"id": "open_conversation_id"}}, - "chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, - "chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, - "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, - "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, - "chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."}, + "chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."}, "drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]}, "drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true}, "ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"}, @@ -84,12 +90,12 @@ "chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, "chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, "chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, - "chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, "chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."}, "contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."}, "contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"}, "contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."}, - "chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."}, + "chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, "chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, "chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."}, "chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."}, @@ -98,8 +104,8 @@ "chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, "chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, "chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, - "chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, - "chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, + "chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, @@ -109,7 +115,7 @@ "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, "doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true}, - "chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."}, + "chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, "doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true}, "doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"}, "doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, @@ -142,7 +148,7 @@ "chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."}, "chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."}, "chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."}, - "chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}, + "chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, "chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."}, "chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."}, "chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."}, @@ -328,7 +334,7 @@ {"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"}, {"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, {"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"}, - {"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"}, + {"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"}, {"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, {"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"}, {"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, @@ -466,6 +472,7 @@ {"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"}, {"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"}, {"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"}, + {"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"}, {"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"}, {"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"}, {"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, diff --git a/internal/cli/schema_catalog_test.go b/internal/cli/schema_catalog_test.go index 0360ae3a..16f7806f 100644 --- a/internal/cli/schema_catalog_test.go +++ b/internal/cli/schema_catalog_test.go @@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) { interfaceType string }{ {"chat message edit", "conversation-id", "openConversationId", true, ""}, - {"chat message edit", "msg-id", "openMessageId", true, ""}, + {"chat message edit", "message-id", "openMessageId", true, ""}, {"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"}, + {"chat message update-text-emotion", "message-id", "openMsgId", true, ""}, {"chat message send", "idempotency-key", "uuid", false, ""}, {"chat message send-card", "at-all", "atAll", false, ""}, {"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"}, - {"chat message update-text-emotion", "msg-id", "openMsgId", true, ""}, {"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""}, {"chat category batch-info", "category-ids", "categoryIds", true, "array"}, - {"chat category list-by-conv", "group", "openConversationId", true, ""}, - {"chat group update-nick", "group", "openConversationId", true, ""}, + {"chat category list-by-conv", "conversation-id", "openConversationId", true, ""}, + {"chat group update-nick", "conversation-id", "", true, ""}, {"chat group upgrade-to-external", "extension", "extension", false, "object"}, {"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""}, {"chat message list-favorites", "size", "", false, "string"}, @@ -1397,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) { } } - // Hidden conversation aliases must stay unpublished (merge-base parity). + // Manifest-covered migrations hide legacy aliases; manifest-external + // commands keep their existing visible flags for compatibility. editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"}) if err != nil { t.Fatal(err) @@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) { t.Fatal(err) } listParams := schemaMap(listByConv["parameters"]) - for _, hidden := range []string{"conversation-id", "id"} { - if _, ok := listParams[hidden]; ok { - t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden) + if _, ok := listParams["conversation-id"]; !ok { + t.Fatalf("chat category list-by-conv missing public canonical --conversation-id") + } + if _, ok := listParams["group"]; !ok { + t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group") + } + if _, ok := listParams["id"]; ok { + t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id") + } + + for _, path := range []string{ + "chat message add-emoji", + "chat message remove-emoji", + "chat message add-text-emotion", + "chat message remove-text-emotion", + } { + leaf, err := queryDeliverySchemaPayload([]string{path}) + if err != nil { + t.Fatal(err) + } + params := schemaMap(leaf["parameters"]) + if _, ok := params["conversation-id"]; !ok { + t.Fatalf("%s missing public canonical --conversation-id", path) + } + for _, visible := range []string{"group", "id", "chat"} { + if _, ok := params[visible]; !ok { + t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible) + } + } + } + + groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"}) + if err != nil { + t.Fatal(err) + } + groupBotsParams := schemaMap(groupBots["parameters"]) + group := groupBotsParams["group"] + if group == nil { + t.Fatal("chat group bots missing public legacy --group") + } + if group["property"] != "openConversationId" { + t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"]) + } + for _, migrated := range []string{"conversation-id", "group-name"} { + if _, ok := groupBotsParams[migrated]; ok { + t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated) } } } diff --git a/internal/cli/schema_constraints_runtime.go b/internal/cli/schema_constraints_runtime.go index ecc7ce71..397a6708 100644 --- a/internal/cli/schema_constraints_runtime.go +++ b/internal/cli/schema_constraints_runtime.go @@ -43,19 +43,14 @@ func init() { RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}}, }) registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all") - registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat") - registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat") registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat") registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat") - registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat") registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id") registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat") registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id") registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id") registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat") registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat") - registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat") - registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat") registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type") registerExclusiveOneOf("chat.send_robot_message", "group", "users") registerRequireOneOf("chat.set_group_member_mute_list", "users", "user") diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index 2595ee69..a68844f7 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "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.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.", + "chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did 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.", @@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag", "chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_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.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_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.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.", "chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string", "chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string", "chat.reply_personal_message --text": "serialized into the aggregate content JSON string", diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 9e61c886..5d0fbe59 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -578,18 +578,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { for _, alias := range flag.Aliases { RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) - if registered := cmd.Flags().Lookup(alias); registered != nil { - runtimeannotate.SetFlagAnnotation( - registered, - runtimeannotate.AnnotationFlagAliasOf, - flag.Name, - ) - runtimeannotate.SetFlagAnnotation( - registered, - runtimeannotate.AnnotationFlagAliasOrigin, - runtimeannotate.FlagAliasOriginCorecmdV1, - ) - } + AnnotateFlagAlias(cmd, alias, flag.Name) } if flag.MarkRequired { _ = cmd.MarkFlagRequired(flag.Name) @@ -600,6 +589,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { } } +// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden +// compatibility alias for canonicalName. It is for commands that already own +// their Cobra flag registration outside FlagSpec but still need the same +// interface-snapshot alias contract as FlagSpec.Aliases. +func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) { + if cmd == nil { + return + } + registered := cmd.Flags().Lookup(aliasName) + if registered == nil { + return + } + runtimeannotate.SetFlagAnnotation( + registered, + runtimeannotate.AnnotationFlagAliasOf, + canonicalName, + ) + runtimeannotate.SetFlagAnnotation( + registered, + runtimeannotate.AnnotationFlagAliasOrigin, + runtimeannotate.FlagAliasOriginCorecmdV1, + ) +} + // RegisterFlag registers one flag by Kind. Default is applied at registration // for every kind so --help DefValue matches the declared fallback. // Malformed KindInt / KindBool Default values panic at registration (fail-closed) diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index 570b268a..a5ae5b96 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) { } } +func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) { + AnnotateFlagAlias(nil, "alias", "canonical") + + cmd := newTestCommand() + AnnotateFlagAlias(cmd, "missing", "canonical") + if flag := cmd.Flags().Lookup("missing"); flag != nil { + t.Fatalf("unexpected missing flag registered: %#v", flag) + } +} + // ── effective value fallback chain ───────────────────────────────── func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) { diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 092f2c84..60918b1b 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -15,9 +15,13 @@ package errors import ( stderrors "errors" + "os" + "path/filepath" "strings" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) { @@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing } } +func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + var b strings.Builder + if err := PrintJSON(&b, NewAPI( + "business error", + WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}), + )); err != nil { + t.Fatalf("PrintJSON() error = %v", err) + } + + want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"` + if got := b.String(); !strings.Contains(got, want) { + t.Fatalf("expected international action_url %q, got %q", want, got) + } +} + func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { t.Parallel() diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index bba9e26c..bd52518f 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -195,6 +195,277 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string return v } +func chatFlagOrAlias(cmd *cobra.Command, primary string, aliases ...string) (string, error) { + value, _ := cmd.Flags().GetString(primary) + value = strings.TrimSpace(value) + for _, alias := range aliases { + aliasValue, _ := cmd.Flags().GetString(alias) + aliasValue = strings.TrimSpace(aliasValue) + if aliasValue == "" { + continue + } + if value != "" && aliasValue != value { + return "", fmt.Errorf("--%s conflicts with --%s", alias, primary) + } + if value == "" { + value = aliasValue + } + } + return value, nil +} + +func requireChatFlagOrAlias(cmd *cobra.Command, primary string, aliases ...string) (string, error) { + value, err := chatFlagOrAlias(cmd, primary, aliases...) + if err != nil { + return "", err + } + if value != "" { + return value, nil + } + return "", validateRequiredFlagWithAliases(cmd, primary, aliases...) +} + +func chatConversationID(cmd *cobra.Command) (string, error) { + return chatFlagOrAlias(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id") +} + +func requireChatConversationID(cmd *cobra.Command) (string, error) { + return requireChatFlagOrAlias(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id") +} + +func chatMessageID(cmd *cobra.Command) (string, error) { + return requireChatFlagOrAlias(cmd, "message-id", "msg-id", "open-message-id") +} + +func installChatIMIDFlagAliases(root *cobra.Command) { + conversationMigrationPaths := chatMigrationPathSet(chatPendingConversationIDMigrationPaths) + messageMigrationPaths := chatMigrationPathSet(chatPendingMessageIDMigrationPaths) + var visit func(*cobra.Command, []string) + visit = func(cmd *cobra.Command, path []string) { + key := strings.Join(path, " ") + if conversationMigrationPaths[key] { + installChatFlagAliases(cmd, "conversation-id", []string{"group", "id", "chat", "open-conversation-id"}, requireChatConversationID) + clearChatAliasRequiredAnnotations(cmd, "group", "id", "chat", "open-conversation-id") + } + if messageMigrationPaths[key] { + installChatFlagAliases(cmd, "message-id", []string{"msg-id", "open-message-id"}, chatMessageID) + clearChatAliasRequiredAnnotations(cmd, "msg-id", "open-message-id") + } + for _, child := range cmd.Commands() { + visit(child, append(path, child.Name())) + } + } + visit(root, nil) + restoreChatGroupBotsLegacyRequired(root) + restoreChatPendingMigrationCanonicalRequired(root) + restoreChatManifestExternalVisibleFlags(root) +} + +func chatMigrationPathSet(paths [][]string) map[string]bool { + set := make(map[string]bool, len(paths)) + for _, path := range paths { + set[strings.Join(path, " ")] = true + } + return set +} + +func restoreChatManifestExternalVisibleFlags(root *cobra.Command) { + if root == nil { + return + } + for _, spec := range chatManifestExternalVisibleFlagSpecs { + cmd, _, err := root.Find(spec.path) + if err != nil || cmd == nil { + continue + } + for _, flag := range spec.flags { + ensureVisibleStringFlag(cmd, flag.name, flag.usage) + } + for _, flag := range spec.optionalFlags { + if f := cmd.Flags().Lookup(flag); f != nil && f.Annotations != nil { + delete(f.Annotations, cobra.BashCompOneRequiredFlag) + } + } + } +} + +func ensureVisibleStringFlag(cmd *cobra.Command, name, usage string) { + flag := cmd.Flags().Lookup(name) + if flag == nil { + cmd.Flags().String(name, "", usage) + flag = cmd.Flags().Lookup(name) + } + if flag != nil { + flag.Hidden = false + } +} + +type chatVisibleFlagSpec struct { + name string + usage string +} + +type chatVisibleFlagCommandSpec struct { + path []string + flags []chatVisibleFlagSpec + optionalFlags []string +} + +var chatManifestExternalVisibleFlagSpecs = []chatVisibleFlagCommandSpec{ + {path: []string{"category", "add-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"category", "list-by-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}, optionalFlags: []string{"conversation-id"}}, + {path: []string{"category", "remove-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"conversation-info"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"file", "upload"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"group", "get-mute-config"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"group-mute"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"group-mute-member"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "add-emoji"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, + {path: []string{"message", "add-text-emotion"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, + {path: []string{"message", "list"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "list-mentions"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "recall-by-bot"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "remove-emoji"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, + {path: []string{"message", "remove-text-emotion"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, + {path: []string{"message", "search"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "send"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "send-by-bot"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, + {path: []string{"message", "send-card"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}, {"receiver", "--open-dingtalk-id 的兼容别名"}}}, + {path: []string{"message", "update-text-emotion"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}, {"id", "--conversation-id 的别名"}, {"chat", "--conversation-id 的别名"}}, optionalFlags: []string{"conversation-id"}}, + {path: []string{"mute"}, flags: []chatVisibleFlagSpec{{"chat", "--conversation-id 的别名"}, {"id", "--conversation-id 的别名"}}}, +} + +func restoreChatGroupBotsLegacyRequired(root *cobra.Command) { + if root == nil { + return + } + cmd, _, err := root.Find([]string{"group", "bots"}) + if err != nil || cmd == nil { + return + } + _ = cmd.MarkFlagRequired("group") +} + +func restoreChatPendingMigrationCanonicalRequired(root *cobra.Command) { + if root == nil { + return + } + for _, path := range chatPendingConversationIDMigrationPaths { + cmd, _, err := root.Find(path) + if err != nil || cmd == nil { + continue + } + _ = cmd.MarkFlagRequired("conversation-id") + clearChatAliasRequiredAnnotations(cmd, "group", "id", "chat", "open-conversation-id") + } + for _, path := range chatPendingMessageIDMigrationPaths { + cmd, _, err := root.Find(path) + if err != nil || cmd == nil { + continue + } + _ = cmd.MarkFlagRequired("message-id") + clearChatAliasRequiredAnnotations(cmd, "msg-id", "open-message-id") + } +} + +var chatPendingConversationIDMigrationPaths = [][]string{ + {"group", "audit-join-validation"}, + {"group", "dismiss"}, + {"group", "invite-url"}, + {"group", "notice", "create"}, + {"group", "notice", "edit"}, + {"group", "notice", "get"}, + {"group", "notice", "list"}, + {"group", "quit"}, + {"group", "set-admin"}, + {"group", "set-history"}, + {"group", "transfer-owner"}, + {"group", "update-alias"}, + {"group", "update-icon"}, + {"group", "update-nick"}, + {"group", "update-settings"}, + {"group", "upgrade-to-external"}, + {"group-role", "add"}, + {"group-role", "list"}, + {"group-role", "query-user"}, + {"group-role", "remove-user"}, + {"group-role", "remove"}, + {"group-role", "set-user"}, + {"group-role", "update"}, + {"message", "list-topic-replies"}, + {"message", "read-status"}, +} + +var chatPendingMessageIDMigrationPaths = [][]string{ + {"message", "edit"}, + {"message", "forward"}, + {"message", "recall"}, + {"message", "set-pin-msg"}, + {"message", "set-top-msg"}, + {"message", "unset-pin-msg"}, + {"message", "unset-top-msg"}, + {"message", "update-text-emotion"}, +} + +func clearChatAliasRequiredAnnotations(cmd *cobra.Command, aliases ...string) { + for _, alias := range aliases { + flag := cmd.Flags().Lookup(alias) + if flag != nil && flag.Annotations != nil { + delete(flag.Annotations, cobra.BashCompOneRequiredFlag) + } + } +} + +func installChatFlagAliases(cmd *cobra.Command, primary string, aliases []string, validate func(*cobra.Command) (string, error)) { + flags := cmd.Flags() + flag := flags.Lookup(primary) + if flag == nil { + return + } + effectiveAliases := make([]string, 0, len(aliases)) + for _, alias := range aliases { + if primary == "conversation-id" && alias == "group" && flags.Lookup("group-name") != nil { + continue + } + effectiveAliases = append(effectiveAliases, alias) + if flags.Lookup(alias) != nil { + if aliasFlag := flags.Lookup(alias); aliasFlag != nil && aliasFlag.Annotations != nil { + delete(aliasFlag.Annotations, cobra.BashCompOneRequiredFlag) + } + _ = flags.MarkHidden(alias) + corecmd.AnnotateFlagAlias(cmd, alias, primary) + continue + } + flags.String(alias, "", "--"+primary+" 的兼容别名") + _ = flags.MarkHidden(alias) + corecmd.AnnotateFlagAlias(cmd, alias, primary) + } + required, ok := flag.Annotations[cobra.BashCompOneRequiredFlag] + wasRequired := flag.Annotations != nil && ok && len(required) > 0 && required[0] == "true" + if wasRequired { + delete(flag.Annotations, cobra.BashCompOneRequiredFlag) + } + previous := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + value, err := chatFlagOrAlias(cmd, primary, effectiveAliases...) + if err != nil { + return err + } + if value != "" { + _ = cmd.Flags().Set(primary, value) + } + if wasRequired { + if _, err := validate(cmd); err != nil { + return err + } + } + if previous != nil { + return previous(cmd, args) + } + return nil + } +} + const maxConversationScopedSearchPages = 40 func runConversationScopedPagedMessageSearch( @@ -578,7 +849,7 @@ func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) { "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, } - if groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat"); groupID != "" { + if groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat"); groupID != "" { toolArgs["openConversationId"] = groupID } return toolArgs, nil @@ -622,7 +893,7 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, } - if groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat"); groupID != "" { + if groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat"); groupID != "" { toolArgs["openConversationId"] = groupID } return toolArgs, nil @@ -1557,7 +1828,7 @@ func stringFromJSONScalar(value any) string { } func buildConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") rawOpenDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") rawUserID := flagOrFallback(cmd, "user", "userId") @@ -2667,17 +2938,17 @@ func newChatCommand() *cobra.Command { chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", - Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, - Example: ` dws chat message list --group - dws chat message list --group --time "2025-03-01 00:00:00" + Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--conversation-id 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, + Example: ` dws chat message list --conversation-id --time "2025-03-01 00:00:00" + dws chat message list --conversation-id dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 - dws chat message list --group --time "2025-03-01 00:00:00" --direction older - dws chat message list --group --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}' - # 查询群 ID: dws chat search --query "群名" - # 查询 userId: dws contact user search --query "姓名"`, + dws chat message list --conversation-id --time "2025-03-01 00:00:00" --direction older + dws chat message list --conversation-id --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}' + # 查询群 ID: dws chat search --query "群名" + # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") specified := 0 @@ -2691,10 +2962,10 @@ func newChatCommand() *cobra.Command { specified++ } if specified > 1 { - return fmt.Errorf("--group, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") + return fmt.Errorf("--conversation-id, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") } if specified == 0 { - return fmt.Errorf("--group, --user or --open-dingtalk-id is required") + return fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required") } if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { @@ -2771,7 +3042,7 @@ func newChatCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "direction", Property: "forward"}, - {Name: "group", Property: "openconversation_id"}, + {Name: "group", Property: "openconversation_id", Required: boolPtr(false)}, }, }, }) @@ -2862,13 +3133,13 @@ func newChatCommand() *cobra.Command { chatMessageSendCmd := &cobra.Command{ Use: "send", - Short: "以当前用户身份发送消息(--group 群聊 / --user 或 --open-dingtalk-id 单聊)", + Short: "以当前用户身份发送消息(--conversation-id 群聊 / --user 或 --open-dingtalk-id 单聊)", Long: `以当前用户身份发送消息。 ⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。 目标选择(三选一,必填): - --group 群聊 openconversation_id + --conversation-id 群聊 openconversation_id --user 单聊接收人 userId --open-dingtalk-id 单聊接收人 openDingTalkId @@ -2889,25 +3160,25 @@ func newChatCommand() *cobra.Command { 旧版内联图片消息: 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。 当前 CLI 不提供本地文件到 mediaId 的上传能力。`, - Example: ` dws chat message send --group "hello" + Example: ` dws chat message send --conversation-id "hello" dws chat message send --user "请查收" dws chat message send --open-dingtalk-id "请查收" - dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" + dws chat message send --conversation-id --title "周报提醒" "请大家本周五前提交周报" # 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示 - dws chat message send --group --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)' + dws chat message send --conversation-id --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)' # 发送本地图片或文件(图片会作为可下载的 file 附件发送) - dws chat message send --group --msg-type file --file-path ./screenshot.png - dws chat message send --group --msg-type file --file-path ./report.pdf + dws chat message send --conversation-id --msg-type file --file-path ./screenshot.png + dws chat message send --conversation-id --msg-type file --file-path ./report.pdf # 发送本地音频/视频(audio/video 是 file 的语义别名) - dws chat message send --group --msg-type audio --file-path ./recording.mp3 - dws chat message send --group --msg-type video --file-path ./demo.mp4 + dws chat message send --conversation-id --msg-type audio --file-path ./recording.mp3 + dws chat message send --conversation-id --msg-type video --file-path ./demo.mp4 # 旧版内联图片:仅当上游已经持有有效 mediaId 时使用 - dws chat message send --group --msg-type image --media-id + dws chat message send --conversation-id --msg-type image --media-id # 查询群 ID: dws chat search --query "群名" # 查询用户 ID: dws contact user search --query "姓名"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid") @@ -2922,10 +3193,10 @@ func newChatCommand() *cobra.Command { specified++ } if specified > 1 { - return fmt.Errorf("--group, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") + return fmt.Errorf("--conversation-id, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") } if specified == 0 { - return fmt.Errorf("--group, --user or --open-dingtalk-id is required") + return fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required") } if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { @@ -3129,30 +3400,30 @@ func newChatCommand() *cobra.Command { Parameters: []contract.ParamDecl{ {Name: "ai-tag", Property: "clawType", InterfaceType: "string"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"}, - {Name: "group", Property: "openConversationId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "idempotency-key", Property: "uuid"}, {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"}, }, }, }) - // send-by-bot: 群聊传 --group,单聊传 --users/--open-dingtalk-ids。 + // send-by-bot: 群聊传 --conversation-id,单聊传 --users/--open-dingtalk-ids。 // Markdown 使用 --title/--text,图片使用 --msg-type image/--image-url, // 文件使用 --msg-type file/--file-path,CLI 上传后发送。 chatMessageSendByBotCmd := &cobra.Command{ Use: "send-by-bot", - Short: "机器人发送消息(--group 群聊 / --users 单聊)", - Long: `群聊:传 --group 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --group 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。 + Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)", + Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。 ⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`, - Example: ` dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..." - dws chat message send-by-bot --robot-code --group --msg-type image --image-url "https://example.com/image.png" - dws chat message send-by-bot --robot-code --group --msg-type file --file-path ./report.pdf + Example: ` dws chat message send-by-bot --robot-code --conversation-id --title "日报" --text "## 今日完成..." + dws chat message send-by-bot --robot-code --conversation-id --msg-type image --image-url "https://example.com/image.png" + dws chat message send-by-bot --robot-code --conversation-id --msg-type file --file-path ./report.pdf dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" dws chat message send-by-bot --robot-code --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报" - dws chat message send-by-bot --robot-code --group --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告" - dws chat message send-by-bot --robot-code --group --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" - dws chat message send-by-bot --robot-code --group --at-all --title "通知" --text "请所有人注意" + dws chat message send-by-bot --robot-code --conversation-id --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告" + dws chat message send-by-bot --robot-code --conversation-id --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" + dws chat message send-by-bot --robot-code --conversation-id --at-all --title "通知" --text "请所有人注意" # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名" # robot-code: $DINGTALK_CHAT_ROBOT_CODE`, @@ -3193,15 +3464,15 @@ func newChatCommand() *cobra.Command { } isMarkdownMessage := msgType == "markdown" - chatID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + chatID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") usersStr, _ := cmd.Flags().GetString("users") openDingtalkIdsStr, _ := cmd.Flags().GetString("open-dingtalk-ids") hasDirectTarget := usersStr != "" || openDingtalkIdsStr != "" if chatID != "" && hasDirectTarget { - return fmt.Errorf("--group and --users/--open-dingtalk-ids are mutually exclusive") + return fmt.Errorf("--conversation-id and --users/--open-dingtalk-ids are mutually exclusive") } if chatID == "" && !hasDirectTarget { - return fmt.Errorf("--group or --users/--open-dingtalk-ids is required") + return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required") } userIDs := splitCommaList(usersStr) @@ -3330,6 +3601,7 @@ func newChatCommand() *cobra.Command { // Keep title/text required_when out of Schema: the runtime switch above // enforces Markdown inputs, while adding it breaks merge-base compatibility. Parameters: []contract.ParamDecl{ + {Name: "group", Property: "group", Required: boolPtr(false)}, {Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}}, {Name: "image-url", RequiredWhen: "msg-type is image"}, {Name: "file-path", RequiredWhen: "msg-type is file"}, @@ -3337,12 +3609,12 @@ func newChatCommand() *cobra.Command { }, }) - // recall-by-bot: 传 --group 为群聊撤回,不传为单聊撤回 + // recall-by-bot: 传 --conversation-id 为群聊撤回,不传为单聊撤回 chatMessageRecallByBotCmd := &cobra.Command{ Use: "recall-by-bot", - Short: "机器人撤回消息(--group 群聊 / 不传为单聊)", - Long: `群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。`, - Example: ` dws chat message recall-by-bot --robot-code --group --keys + Short: "机器人撤回消息(--conversation-id 群聊 / 不传为单聊)", + Long: `群聊:传 --conversation-id 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。`, + Example: ` dws chat message recall-by-bot --robot-code --conversation-id --keys dws chat message recall-by-bot --robot-code --keys key1,key2 # 查询群 ID: dws chat search --query "群名" # robot-code: $DINGTALK_CHAT_ROBOT_CODE`, @@ -3357,7 +3629,7 @@ func newChatCommand() *cobra.Command { processQueryKeys = append(processQueryKeys, s) } } - chatID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + chatID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if chatID != "" { return callMCPToolOnServer("bot", "recall_robot_group_message", map[string]any{ "robotCode": mustGetFlag(cmd, "robot-code"), @@ -3394,7 +3666,7 @@ func newChatCommand() *cobra.Command { AgentSummary: "撤回指定机器人发送的消息", UseWhen: []string{"持有 robotCode 和发送结果 key,需要撤回机器人消息时"}, AvoidWhen: []string{"个人身份消息撤回使用 chat message recall"}, - Examples: []string{"dws chat message recall-by-bot --robot-code --group --keys "}, + Examples: []string{"dws chat message recall-by-bot --robot-code --conversation-id --keys "}, }, }, }) @@ -3502,19 +3774,19 @@ func newChatCommand() *cobra.Command { chatMessageListTopicRepliesCmd := &cobra.Command{ Use: "list-topic-replies", Short: "拉取群话题回复消息列表", - Long: `查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。`, - Example: ` dws chat message list-topic-replies --group --topic-id - dws chat message list-topic-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20 + Long: `查询指定群聊中某条话题消息的全部回复。--conversation-id 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。`, + Example: ` dws chat message list-topic-replies --conversation-id --topic-id + dws chat message list-topic-replies --conversation-id --topic-id --time "2025-03-01 00:00:00" --limit 20 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "group", "conversation-id", "id", "chat"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } if err := validateRequiredFlags(cmd, "topic-id"); err != nil { return err } toolArgs := map[string]any{ - "openconversationId": flagOrFallback(cmd, "group", "conversation-id", "id", "chat"), + "openconversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "topicId": mustGetFlag(cmd, "topic-id"), } if v, _ := cmd.Flags().GetString("time"); v != "" { @@ -3554,11 +3826,11 @@ func newChatCommand() *cobra.Command { AgentSummary: "分页读取指定话题的回复", UseWhen: []string{"已知话题 ID 并需要查看回复串时"}, AvoidWhen: []string{"读取普通会话消息时使用 chat message list"}, - Examples: []string{"dws chat message list-topic-replies --group --topic-id --limit 50"}, + Examples: []string{"dws chat message list-topic-replies --conversation-id --topic-id --limit 50"}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openconversationId"}, {Name: "direction", Property: "forward"}, - {Name: "group", Property: "openconversationId"}, {Name: "limit", Property: "pageSize"}, {Name: "time", Property: "startTime"}, }, @@ -3670,9 +3942,9 @@ func newChatCommand() *cobra.Command { Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 - dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message list-mentions --conversation-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor - dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 + dws chat message list-mentions --conversation-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( @@ -3709,7 +3981,7 @@ func newChatCommand() *cobra.Command { }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, - {Name: "group", Property: "openConversationId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, @@ -3855,15 +4127,15 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --conversation-id 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 - dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 + dws chat message search --query "codereview" --conversation-id --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}' # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") return runConversationScopedPagedMessageSearch( cmd, pagedProjectedChatSearchConfig(cmd, "search_messages_by_keyword", chatMessageSearchArgs), @@ -3902,7 +4174,7 @@ func newChatCommand() *cobra.Command { }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, - {Name: "group", Property: "openConversationId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "query", Property: "keyword"}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), @@ -3991,10 +4263,10 @@ func newChatCommand() *cobra.Command { Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。 发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为 -chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 +chat message edit 或 chat message recall 的 --message-id 和 --conversation-id。 同一组 ID 也可继续用于 chat message read-status(消息参数为 --message-id);openTaskId 本身不是消息 ID。`, Example: ` dws chat message query-send-status --open-task-id - dws chat message recall --conversation-id --msg-id `, + dws chat message recall --conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-task-id"); err != nil { return err @@ -4039,22 +4311,22 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Use: "recall", Short: "撤回用户发送的消息", Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`, - Example: ` dws chat message recall --conversation-id --msg-id + Example: ` dws chat message recall --conversation-id --message-id # 发送后撤回:send -> query-send-status -> recall - dws chat message send --group --text "待撤回的内容" + dws chat message send --conversation-id --text "待撤回的内容" dws chat message query-send-status --open-task-id <上一步返回的openTaskId> - dws chat message recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>`, + dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } return callMCPToolOnServer("im", "recall_message", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } @@ -4084,11 +4356,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 "撤回机器人消息时使用 chat message recall-by-bot", "消息 ID 未确认时先用消息查询/搜索拿到 openMessageId", }, - Examples: []string{"dws chat message recall --conversation-id --msg-id "}, + Examples: []string{"dws chat message recall --conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, - {Name: "msg-id", Property: "openMessageId"}, + {Name: "message-id", Property: "openMessageId"}, }, }, }) @@ -4100,22 +4372,22 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。 也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`, - Example: ` dws chat message edit --conversation-id --msg-id --text "更新后的内容" + Example: ` dws chat message edit --conversation-id --message-id --text "更新后的内容" # 发送后编辑:send -> query-send-status -> edit - dws chat message send --group --text "原始内容" + dws chat message send --conversation-id --text "原始内容" dws chat message query-send-status --open-task-id <上一步返回的openTaskId> - dws chat message edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId> --text "更新后的内容" + dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容" - dws chat message edit --group --msg-id --title "标题" --text "更新后的内容" - dws chat message edit --group --msg-id --text "<@all> 请查看" --at-all - dws chat message edit --group --msg-id --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids - dws chat message edit --group --msg-id --content '{"title":"标题","text":"更新后的内容"}'`, + dws chat message edit --conversation-id --message-id --title "标题" --text "更新后的内容" + dws chat message edit --conversation-id --message-id --text "<@all> 请查看" --at-all + dws chat message edit --conversation-id --message-id --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids + dws chat message edit --conversation-id --message-id --content '{"title":"标题","text":"更新后的内容"}'`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } content, _ := cmd.Flags().GetString("content") @@ -4144,7 +4416,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), "content": content, } if atAll { @@ -4179,7 +4451,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "编辑当前用户已发送消息的 Markdown 内容", UseWhen: []string{"已有会话 openConversationId 和消息 openMessageId,需要更正已发送消息的标题、正文或 @ 信息"}, AvoidWhen: []string{"发送新消息应使用 chat message send;撤回消息应使用 chat message recall"}, - Examples: []string{"dws chat message edit --conversation-id --msg-id --text \"更新后的内容\""}, + Examples: []string{"dws chat message edit --conversation-id --message-id --text \"更新后的内容\""}, }, Parameters: []contract.ParamDecl{ {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, @@ -4187,9 +4459,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "content", Property: "content", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "msg-id", Property: "openMessageId", Required: boolPtr(true)}, + {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "text", Property: "text", Required: boolPtr(false)}, {Name: "title", Property: "title", Required: boolPtr(false)}, }, @@ -4205,18 +4476,20 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 dws chat message read-status --conversation-id --message-id --users userId1,userId2 dws chat message read-status --conversation-id --message-id --target-open-dingtalk-ids openDingTalkId1,openDingTalkId2 # 查询会话 ID: dws chat search --query "群名" - # 查询 openMessageId: dws chat message list --group --time "2025-03-01 00:00:00" + # 查询 openMessageId: dws chat message list --conversation-id --time "2025-03-01 00:00:00" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { + conversationID, err := requireChatConversationID(cmd) + if err != nil { return err } - if err := validateRequiredFlags(cmd, "message-id"); err != nil { + messageID, err := chatMessageID(cmd) + if err != nil { return err } toolArgs := map[string]any{ - "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMessageId": mustGetFlag(cmd, "message-id"), + "openConversationId": conversationID, + "openMessageId": messageID, } if usersStr := flagOrFallback(cmd, "users", "user", "userId"); usersStr != "" { appendChatIDArgs(toolArgs, parseCSVValues(usersStr), "targetUserIds", "targetOpenDingTalkIds") @@ -4410,16 +4683,20 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd) // message 子命令 flags - chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)") + chatMessageListCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(群聊时必填)") chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)") - chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") + chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认上海时间当前时间)") chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)") chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)") _ = chatMessageListCmd.Flags().MarkHidden("forward") chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制") chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListCmd.Flags().MarkHidden("size") + cli.AnnotateRuntimeConstraints(chatMessageListCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, + RequireOneOf: [][]string{{"group", "user", "open-dingtalk-id"}}, + }) chatMessageListDirectCmd.Flags().String("user", "", "对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)") chatMessageListDirectCmd.Flags().String("open-dingtalk-id", "", "对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)") chatMessageListDirectCmd.Flags().String("time", "", "开始时间,格式 yyyy-MM-dd HH:mm:ss (必填)") @@ -4430,7 +4707,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListDirectCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListDirectCmd.Flags().MarkHidden("size") - chatMessageSendCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)") + chatMessageSendCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(群聊时必填)") chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)") chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)") @@ -4488,7 +4765,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)") _ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code") - chatMessageSendByBotCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填)") + chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)") chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)") chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)") chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)") @@ -4499,11 +4776,15 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)") chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)") chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false") + cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"group", "users"}}, + RequireOneOf: [][]string{{"group", "users"}}, + }) cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path") chatMessageRecallByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)") _ = chatMessageRecallByBotCmd.MarkFlagRequired("robot-code") - chatMessageRecallByBotCmd.Flags().String("group", "", "群聊 openConversationId(群聊撤回时必填)") + chatMessageRecallByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊撤回时必填)") chatMessageRecallByBotCmd.Flags().String("keys", "", "消息 processQueryKey 列表,逗号分隔 (必填)") _ = chatMessageRecallByBotCmd.MarkFlagRequired("keys") @@ -4523,8 +4804,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 _ = chatBotSearchCmd.Flags().MarkHidden("limit") chatBotSearchCmd.Flags().String("name", "", "按名称搜索") - chatMessageListTopicRepliesCmd.Flags().String("group", "", "群会话 openconversationId (必填)") - _ = chatMessageListTopicRepliesCmd.MarkFlagRequired("group") + chatMessageListTopicRepliesCmd.Flags().String("conversation-id", "", "群会话 openconversationId (必填)") + _ = chatMessageListTopicRepliesCmd.MarkFlagRequired("conversation-id") chatMessageListTopicRepliesCmd.Flags().String("topic-id", "", "话题 ID,由 dws chat message list 返回 (必填)") _ = chatMessageListTopicRepliesCmd.MarkFlagRequired("topic-id") chatMessageListTopicRepliesCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)") @@ -4557,7 +4838,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AddPagedMCPFlags(chatMessageListBySenderCmd) // list-mentions flags - chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)") + chatMessageListMentionsCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(可选,不传则查全部)") chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") @@ -4583,7 +4864,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSearchCmd.Flags().String("query", "", "搜索关键词 (必填)") chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名") _ = chatMessageSearchCmd.Flags().MarkHidden("keyword") - chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)") + chatMessageSearchCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(可选,不传则搜索所有会话)") chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") @@ -4676,8 +4957,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 _ = chatMessageRecallCmd.Flags().MarkHidden("id") chatMessageRecallCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMessageRecallCmd.Flags().MarkHidden("chat") - chatMessageRecallCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageRecallCmd.MarkFlagRequired("msg-id") + chatMessageRecallCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageRecallCmd.MarkFlagRequired("message-id") // edit flags chatMessageEditCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") @@ -4687,8 +4968,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 _ = chatMessageEditCmd.Flags().MarkHidden("id") chatMessageEditCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMessageEditCmd.Flags().MarkHidden("chat") - chatMessageEditCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageEditCmd.MarkFlagRequired("msg-id") + chatMessageEditCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageEditCmd.MarkFlagRequired("message-id") chatMessageEditCmd.Flags().String("text", "", "编辑后的 Markdown 正文;与 --content 二选一") chatMessageEditCmd.Flags().String("title", "", "消息标题;配合 --text 使用,未传时从正文自动生成") chatMessageEditCmd.Flags().String("content", "", "完整 Markdown content JSON;与 --text 二选一") @@ -4707,8 +4988,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSearchCmd, } for _, c := range groupAliasCmds { - c.Flags().String("conversation-id", "", "--group 的别名") - _ = c.Flags().MarkHidden("conversation-id") + c.Flags().String("group", "", "--conversation-id 的别名") + _ = c.Flags().MarkHidden("group") if c.Flags().Lookup("id") == nil { c.Flags().String("id", "", "--group 的别名") _ = c.Flags().MarkHidden("id") @@ -4723,11 +5004,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Short: "获取会话基础信息", Long: `获取指定会话的基础信息。 发送本地文件消息请优先使用 dws chat message send --msg-type file --file-path <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`, - Example: ` dws chat conversation-info --group --format json + Example: ` dws chat conversation-info --conversation-id --format json dws chat conversation-info --user --format json dws chat conversation-info --open-dingtalk-id --format json`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") rawOpenDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") rawUserID := flagOrFallback(cmd, "user", "userId") specified := 0 @@ -4800,23 +5081,27 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Examples: []string{"dws chat conversation-info --group --format json"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, {Name: "open-dingtalk-id", Property: "openDingTalkId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "user", Property: "openDingTalkId"}, }, }, }) - chatConversationInfoCmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)") - chatConversationInfoCmd.Flags().String("conversation-id", "", "--group 的别名") + chatConversationInfoCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)") + chatConversationInfoCmd.Flags().String("group", "", "--conversation-id 的别名") chatConversationInfoCmd.Flags().String("id", "", "--group 的别名") chatConversationInfoCmd.Flags().String("chat", "", "--group 的别名") - _ = chatConversationInfoCmd.Flags().MarkHidden("conversation-id") + _ = chatConversationInfoCmd.Flags().MarkHidden("group") _ = chatConversationInfoCmd.Flags().MarkHidden("id") _ = chatConversationInfoCmd.Flags().MarkHidden("chat") chatConversationInfoCmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)") chatConversationInfoCmd.Flags().String("userId", "", "--user 的别名") _ = chatConversationInfoCmd.Flags().MarkHidden("userId") chatConversationInfoCmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)") + cli.AnnotateRuntimeConstraints(chatConversationInfoCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, + RequireOneOf: [][]string{{"group", "user", "open-dingtalk-id"}}, + }) // ── file 子命令(会话文件上传,不暴露 spaceId)─────────────── @@ -4834,15 +5119,15 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。 发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`, - Example: ` dws chat message send --group --msg-type file --file-path ./report.pdf --format json + Example: ` dws chat message send --conversation-id --msg-type file --file-path ./report.pdf --format json dws chat message send --open-dingtalk-id --msg-type file --file-path ./report.pdf --format json`, RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>") }, } - chatFileUploadCmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)") - chatFileUploadCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatFileUploadCmd.Flags().MarkHidden("conversation-id") + chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)") + chatFileUploadCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatFileUploadCmd.Flags().MarkHidden("group") chatFileUploadCmd.Flags().String("id", "", "--group 的别名") _ = chatFileUploadCmd.Flags().MarkHidden("id") chatFileUploadCmd.Flags().String("chat", "", "--group 的别名") @@ -5107,13 +5392,13 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Use: "add-conv", Short: "将会话移动到指定的自定义分组中", Long: `将某个会话添加到一批用户自定义会话分组中。需指定会话 openConversationId 和目标分组 ID 列表。`, - Example: ` dws chat category add-conv --group --category-ids 123,456 + Example: ` dws chat category add-conv --conversation-id --category-ids 123,456 # 分组ID 可通过 dws chat category list 获取 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { - return fmt.Errorf("flag --group is required") + return fmt.Errorf("flag --conversation-id is required") } if err := validateRequiredFlags(cmd, "category-ids"); err != nil { return err @@ -5151,7 +5436,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "将会话加入一个或多个自定义会话分组", UseWhen: []string{"已有会话 openConversationId 和目标 categoryId,需要把会话归入分组时"}, AvoidWhen: []string{"从分组移除会话时使用 chat category remove-conv"}, - Examples: []string{"dws chat category add-conv --group --category-ids 123,456"}, + Examples: []string{"dws chat category add-conv --conversation-id --category-ids 123,456"}, }, Parameters: []contract.ParamDecl{ {Name: "category-ids", Property: "categoryIds", Required: boolPtr(true), InterfaceType: "array"}, @@ -5166,11 +5451,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Use: "remove-conv", Short: "将会话从指定的自定义分组中移出", Long: `将某个会话从一批用户自定义会话分组中移出。需指定会话 openConversationId 和目标分组 ID 列表。`, - Example: ` dws chat category remove-conv --group --category-ids 123,456 + Example: ` dws chat category remove-conv --conversation-id --category-ids 123,456 # 分组ID 可通过 dws chat category list 获取 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { return fmt.Errorf("flag --group is required") } @@ -5210,7 +5495,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "将会话从一个或多个自定义会话分组移出", UseWhen: []string{"已有会话 openConversationId 和 categoryId,需要取消会话分组归属时"}, AvoidWhen: []string{"向分组加入会话时使用 chat category add-conv"}, - Examples: []string{"dws chat category remove-conv --group --category-ids 123,456"}, + Examples: []string{"dws chat category remove-conv --conversation-id --category-ids 123,456"}, }, Parameters: []contract.ParamDecl{ {Name: "category-ids", Property: "categoryIds", Required: boolPtr(true), InterfaceType: "array"}, @@ -5225,15 +5510,14 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 Use: "list-by-conv", Short: "拉取指定会话所属的用户自定义会话分组", Long: `拉取指定会话所属的用户自定义会话分组。需指定会话 openConversationId。`, - Example: ` dws chat category list-by-conv --group + Example: ` dws chat category list-by-conv --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id") - if groupID == "" { - return fmt.Errorf("flag --group is required") + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id"); err != nil { + return err } return callMCPToolOnServer("im", "list_conv_categories_by_conv", map[string]any{ - "openConversationId": groupID, + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id"), }) }, } @@ -5260,11 +5544,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "查询指定会话所属的自定义会话分组", UseWhen: []string{"已有会话 openConversationId,需要反查该会话被放入了哪些自定义分组"}, AvoidWhen: []string{"列出全部自定义分组应使用 chat category list;按 categoryId 查详情应使用 chat category batch-info"}, - Examples: []string{"dws chat category list-by-conv --group "}, + Examples: []string{"dws chat category list-by-conv --conversation-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, @@ -5418,18 +5702,21 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageAddEmojiCmd := &cobra.Command{ Use: "add-emoji", Short: "对消息添加 emoji 表情回应", - Example: ` dws chat message add-emoji --conversation-id --msg-id --emoji "赞" + Example: ` dws chat message add-emoji --conversation-id --message-id --emoji "赞" # 查询会话 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil { + if _, err := chatMessageID(cmd); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "emoji"); err != nil { return err } return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{ - "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMsgId": mustGetFlag(cmd, "msg-id"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), + "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emojiName": mustGetFlag(cmd, "emoji"), }) }, @@ -5457,15 +5744,16 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "给指定消息添加表情回应", UseWhen: []string{"需要对已有消息添加一个 emoji reaction 时"}, AvoidWhen: []string{"发送文本消息或文字表情时不要使用"}, - Examples: []string{"dws chat message add-emoji --conversation-id --msg-id --emoji \"赞\""}, + Examples: []string{"dws chat message add-emoji --conversation-id --message-id --emoji \"赞\""}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, + {Name: "emoji", Property: "emojiName"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "emoji", Property: "emojiName"}, - {Name: "msg-id", Property: "openMsgId"}, + {Name: "message-id", Property: "openMsgId"}, + {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) @@ -5473,24 +5761,31 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageAddEmojiCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("chat", "", "--conversation-id 的别名") - chatMessageAddEmojiCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)") + chatMessageAddEmojiCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") + chatMessageAddEmojiCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageAddEmojiCmd.Flags().String("emoji", "", "emoji 表情名称 (必填)") + cli.AnnotateRuntimeConstraints(chatMessageAddEmojiCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, + }) chatMessageRemoveEmojiCmd := &cobra.Command{ Use: "remove-emoji", Short: "移除消息的 emoji 表情回应", - Example: ` dws chat message remove-emoji --conversation-id --msg-id --emoji "赞" + Example: ` dws chat message remove-emoji --conversation-id --message-id --emoji "赞" # 查询会话 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil { + if _, err := chatMessageID(cmd); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "emoji"); err != nil { return err } return callMCPToolOnServer("im", "remove_emoji_reaction", map[string]any{ - "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMsgId": mustGetFlag(cmd, "msg-id"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), + "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emojiName": mustGetFlag(cmd, "emoji"), }) }, @@ -5518,15 +5813,16 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "移除指定消息上的表情回应", UseWhen: []string{"需要取消此前添加的 emoji reaction 时"}, AvoidWhen: []string{"移除文字表情时使用 chat message remove-text-emotion"}, - Examples: []string{"dws chat message remove-emoji --conversation-id --msg-id --emoji \"赞\""}, + Examples: []string{"dws chat message remove-emoji --conversation-id --message-id --emoji \"赞\""}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, + {Name: "emoji", Property: "emojiName"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "emoji", Property: "emojiName"}, - {Name: "msg-id", Property: "openMsgId"}, + {Name: "message-id", Property: "openMsgId"}, + {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) @@ -5534,23 +5830,30 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageRemoveEmojiCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("chat", "", "--conversation-id 的别名") - chatMessageRemoveEmojiCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)") + chatMessageRemoveEmojiCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") + chatMessageRemoveEmojiCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageRemoveEmojiCmd.Flags().String("emoji", "", "emoji 表情名称 (必填)") + cli.AnnotateRuntimeConstraints(chatMessageRemoveEmojiCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, + }) chatMessageAddTextEmotionCmd := &cobra.Command{ Use: "add-text-emotion", Short: "对消息添加文字表情回应", - Example: ` dws chat message add-text-emotion --conversation-id --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`, + Example: ` dws chat message add-text-emotion --conversation-id --message-id --emotion-id --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 { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id", "emotion-id", "emotion-name", "text", "background-id"); err != nil { + if _, err := chatMessageID(cmd); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "emotion-id", "emotion-name", "text", "background-id"); err != nil { return err } return callMCPToolOnServer("im", "add_text_emotion", map[string]any{ - "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMsgId": mustGetFlag(cmd, "msg-id"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), + "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), @@ -5581,14 +5884,15 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "给指定消息添加已定义的文字表情", UseWhen: []string{"已有文字表情定义并要附加到消息时"}, AvoidWhen: []string{"需要先创建文字表情资源时使用 chat message create-text-emotion"}, - Examples: []string{"dws chat message add-text-emotion --conversation-id --msg-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, + Examples: []string{"dws chat message add-text-emotion --conversation-id --message-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "msg-id", Property: "openMsgId"}, + {Name: "message-id", Property: "openMsgId"}, + {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) @@ -5596,26 +5900,33 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageAddTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名") - chatMessageAddTextEmotionCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)") + chatMessageAddTextEmotionCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") + chatMessageAddTextEmotionCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageAddTextEmotionCmd.Flags().String("emotion-id", "", "表情 ID (必填,通过 create-text-emotion 获取)") chatMessageAddTextEmotionCmd.Flags().String("emotion-name", "", "表情名称 (必填)") chatMessageAddTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)") chatMessageAddTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)") + cli.AnnotateRuntimeConstraints(chatMessageAddTextEmotionCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, + }) chatMessageRemoveTextEmotionCmd := &cobra.Command{ Use: "remove-text-emotion", Short: "移除消息的文字表情回应", - Example: ` dws chat message remove-text-emotion --conversation-id --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id `, + Example: ` dws chat message remove-text-emotion --conversation-id --message-id --emotion-id --emotion-name "赞" --text "nice" --background-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } - if err := validateRequiredFlags(cmd, "msg-id", "emotion-id", "emotion-name", "text", "background-id"); err != nil { + if _, err := chatMessageID(cmd); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "emotion-id", "emotion-name", "text", "background-id"); err != nil { return err } return callMCPToolOnServer("im", "remove_text_emotion", map[string]any{ - "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), - "openMsgId": mustGetFlag(cmd, "msg-id"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), + "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), @@ -5646,14 +5957,15 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 AgentSummary: "移除指定消息上的文字表情回应", UseWhen: []string{"需要取消已添加的文字表情时"}, AvoidWhen: []string{"移除普通 emoji reaction 时使用 chat message remove-emoji"}, - Examples: []string{"dws chat message remove-text-emotion --conversation-id --msg-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, + Examples: []string{"dws chat message remove-text-emotion --conversation-id --message-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "msg-id", Property: "openMsgId"}, + {Name: "message-id", Property: "openMsgId"}, + {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) @@ -5661,23 +5973,33 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageRemoveTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名") - chatMessageRemoveTextEmotionCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)") + chatMessageRemoveTextEmotionCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") + chatMessageRemoveTextEmotionCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("emotion-id", "", "表情 ID (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("emotion-name", "", "表情名称 (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)") + cli.AnnotateRuntimeConstraints(chatMessageRemoveTextEmotionCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, + }) chatMessageUpdateTextEmotionCmd := &cobra.Command{ Use: "update-text-emotion", Short: "更新消息的文字表情回应", - Example: ` dws chat message update-text-emotion --conversation-id --msg-id --old-emotion-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`, + Example: ` dws chat message update-text-emotion --conversation-id --message-id --old-emotion-id --emotion-id --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 { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { + return err + } + if _, err := chatMessageID(cmd); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"); 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"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), + "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "oldEmotionId": mustGetFlag(cmd, "old-emotion-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), @@ -5712,36 +6034,38 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 "消息上还没有文字表情时使用 chat message add-text-emotion", "只需清除文字表情时使用 chat message remove-text-emotion", }, - Examples: []string{"dws chat message update-text-emotion --conversation-id --msg-id --old-emotion-id --emotion-id --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"}, + Examples: []string{"dws chat message update-text-emotion --conversation-id --message-id --old-emotion-id --emotion-id --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "background-id", Property: "backgroundId", Required: boolPtr(true), Description: "新文字表情的背景 ID"}, - {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId;与 --group、--id、--chat 四选一"}, + {Name: "chat", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId"}, {Name: "emotion-id", Property: "emotionId", Required: boolPtr(true), Description: "新的文字表情 ID,可通过 create-text-emotion 获取"}, {Name: "emotion-name", Property: "emotionName", Required: boolPtr(true), Description: "新的文字表情名称"}, - {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, - {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true), Description: "需要原地更新文字表情的消息 openMsgId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, + {Name: "id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, + {Name: "message-id", Property: "openMsgId", Required: boolPtr(true), Description: "需要原地更新文字表情的消息 openMsgId"}, {Name: "old-emotion-id", Property: "oldEmotionId", Required: boolPtr(true), Description: "消息上当前文字表情的 emotionId"}, + {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, {Name: "text", Property: "text", Required: boolPtr(true), Description: "新的文字表情内容"}, }, }, }) 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("open-conversation-id", "", "--conversation-id 的别名") + chatMessageUpdateTextEmotionCmd.Flags().String("message-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("conversation-id") + for _, name := range []string{"message-id", "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"} { _ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired(name) } + cli.AnnotateRuntimeConstraints(chatMessageUpdateTextEmotionCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, + }) // ── 创建文字表情(获取 emotionId)────────────────────── @@ -5803,32 +6127,32 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSendCardCmd := &cobra.Command{ Use: "send-card", Short: "创建并推送流式卡片", - Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。 + Long: `向群聊或单聊创建并推送流式卡片。群聊传 --conversation-id,单聊传 --open-dingtalk-id,二者互斥。 群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。 创建时无需传入卡片内容,后续通过 update-card 更新内容。 注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容, 最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。 flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`, - Example: ` dws chat message send-card --group - dws chat message send-card --group --at-open-dingtalk-ids - dws chat message send-card --group --at-all - dws chat message send-card --receiver + Example: ` dws chat message send-card --conversation-id + dws chat message send-card --conversation-id --at-open-dingtalk-ids + dws chat message send-card --conversation-id --at-all + dws chat message send-card --open-dingtalk-id # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") - receiver, _ := cmd.Flags().GetString("receiver") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") + receiver := flagOrFallback(cmd, "open-dingtalk-id", "receiver") atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids"))) atAll, _ := cmd.Flags().GetBool("at-all") if groupID == "" && receiver == "" { - return fmt.Errorf("--group or --receiver is required") + return fmt.Errorf("--conversation-id or --open-dingtalk-id is required") } if groupID != "" && receiver != "" { - return fmt.Errorf("--group and --receiver are mutually exclusive") + return fmt.Errorf("--conversation-id and --open-dingtalk-id are mutually exclusive") } if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) { - return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group") + return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --conversation-id") } toolArgs := map[string]any{} if groupID != "" { @@ -5878,15 +6202,23 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Parameters: []contract.ParamDecl{ {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"}, - {Name: "group", Property: "openConversationId"}, - {Name: "receiver", Property: "receiverOpenDingTalkId"}, + {Name: "conversation-id", Property: "openConversationId"}, + {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, + {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"}, + {Name: "receiver", Property: "receiverOpenDingTalkId", Required: boolPtr(false)}, }, }, }) - chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)") - chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)") - chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)") - chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)") + chatMessageSendCardCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填,与 --open-dingtalk-id 互斥)") + chatMessageSendCardCmd.Flags().String("open-dingtalk-id", "", "单聊接收者 openDingTalkId(单聊时必填,与 --conversation-id 互斥)") + chatMessageSendCardCmd.Flags().String("receiver", "", "--open-dingtalk-id 的兼容别名") + _ = chatMessageSendCardCmd.Flags().MarkHidden("receiver") + chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --conversation-id 一起使用)") + chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --conversation-id 一起使用)") + cli.AnnotateRuntimeConstraints(chatMessageSendCardCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"group", "receiver"}}, + RequireOneOf: [][]string{{"group", "receiver"}}, + }) chatMessageUpdateCardCmd := &cobra.Command{ Use: "update-card", @@ -6174,13 +6506,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupTransferOwnerCmd := &cobra.Command{ Use: "transfer-owner", Short: "转让群主", - Example: ` dws chat group transfer-owner --group --new-owner - dws chat group transfer-owner --group --user + Example: ` dws chat group transfer-owner --conversation-id --new-owner + dws chat group transfer-owner --conversation-id --user # 查询群 ID: dws chat search --query "群名" # 查询 openDingTalkId: dws contact user search --query "姓名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } newOwnerOpenDingTalkID, _ := cmd.Flags().GetString("new-owner") @@ -6197,12 +6529,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } if !isOpenDingTalkID(newOwner) { return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "newOwnerUid": newOwner, }) } return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "newOwnerOpenDingTalkId": newOwner, }) }, @@ -6230,16 +6562,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "把群主身份转让给指定群成员", UseWhen: []string{"现群主明确指定新群主时"}, AvoidWhen: []string{"只是授予管理员权限时使用 chat group set-admin"}, - Examples: []string{"dws chat group transfer-owner --group --new-owner "}, + Examples: []string{"dws chat group transfer-owner --conversation-id --new-owner "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "new-owner", Property: "newOwnerOpenDingTalkId"}, }, }, }) - chatGroupTransferOwnerCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupTransferOwnerCmd.MarkFlagRequired("group") + chatGroupTransferOwnerCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupTransferOwnerCmd.MarkFlagRequired("conversation-id") chatGroupTransferOwnerCmd.Flags().String("new-owner", "", "新群主 openDingTalkId") chatGroupTransferOwnerCmd.Flags().String("user", "", "新群主 userId") chatGroupTransferOwnerCmd.Flags().String("userId", "", "--user 的别名") @@ -6249,16 +6581,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "invite-url", Short: "获取群邀请链接", Long: `获取群聊邀请链接。可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。`, - Example: ` dws chat group invite-url --group - dws chat group invite-url --group --expires-seconds 86400 - dws chat group invite-url --group --expires-seconds 0 + Example: ` dws chat group invite-url --conversation-id + dws chat group invite-url --conversation-id --expires-seconds 86400 + dws chat group invite-url --conversation-id --expires-seconds 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if v, _ := cmd.Flags().GetInt64("expires-seconds"); v >= 0 && cmd.Flags().Changed("expires-seconds") { toolArgs["expiresSeconds"] = v @@ -6289,15 +6621,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "获取指定群聊的邀请链接", UseWhen: []string{"需要生成群邀请链接或设置有效期时"}, AvoidWhen: []string{"需要直接添加已知成员时使用 chat group members add"}, - Examples: []string{"dws chat group invite-url --group --expires-seconds 86400"}, + Examples: []string{"dws chat group invite-url --conversation-id --expires-seconds 86400"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupInviteUrlCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupInviteUrlCmd.MarkFlagRequired("group") + chatGroupInviteUrlCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupInviteUrlCmd.MarkFlagRequired("conversation-id") chatGroupInviteUrlCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值") chatMuteCmd := &cobra.Command{ @@ -6364,14 +6696,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "quit", Short: "退出群聊", Long: `当前用户退出指定群聊。退出后将无法查看群消息。`, - Example: ` dws chat group quit --group + Example: ` dws chat group quit --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "quit_group", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), }) }, } @@ -6401,24 +6733,24 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 "需要永久解散整个群时使用 chat group dismiss", "需要踢出其他成员时使用 chat group members remove", }, - Examples: []string{"dws chat group quit --group "}, + Examples: []string{"dws chat group quit --conversation-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupQuitCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupQuitCmd.MarkFlagRequired("group") + chatGroupQuitCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupQuitCmd.MarkFlagRequired("conversation-id") chatGroupUpdateIconCmd := &cobra.Command{ Use: "update-icon", Short: "更新群头像", Long: `更新指定群聊的群头像。需传入群 ID 和头像 mediaId。`, - Example: ` dws chat group update-icon --group --icon-media-id + Example: ` dws chat group update-icon --conversation-id --icon-media-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "icon-media-id"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "icon-media-id"); err != nil { return err } iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id")) @@ -6426,7 +6758,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 请使用上游媒体上传能力返回的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传命令") } return callMCPToolOnServer("im", "update_group_icon", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "iconMediaId": iconMediaID, }) }, @@ -6454,15 +6786,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "使用真实媒体 ID 更新群头像", UseWhen: []string{"已有上传后的头像 mediaId 并要修改群头像时"}, AvoidWhen: []string{"没有真实可用 mediaId 时先完成媒体上传"}, - Examples: []string{"dws chat group update-icon --group --icon-media-id @mediaId"}, + Examples: []string{"dws chat group update-icon --conversation-id --icon-media-id @mediaId"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupUpdateIconCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupUpdateIconCmd.MarkFlagRequired("group") + chatGroupUpdateIconCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupUpdateIconCmd.MarkFlagRequired("conversation-id") chatGroupUpdateIconCmd.Flags().String("icon-media-id", "", "群头像 mediaId (必填)") _ = chatGroupUpdateIconCmd.MarkFlagRequired("icon-media-id") @@ -6489,11 +6821,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 groupRedEnvelopeSwitch 发红包 groupLiveAuthority 谁可以发起直播 groupBillAuthority 群收款开关`, - Example: ` dws chat group update-settings --group --setting-key searchable --status 1 - dws chat group update-settings --group --setting-key onlyAdminCanAtAll --status 0 + Example: ` dws chat group update-settings --conversation-id --setting-key searchable --status 1 + dws chat group update-settings --conversation-id --setting-key onlyAdminCanAtAll --status 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "setting-key"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "setting-key"); err != nil { return err } if !cmd.Flags().Changed("status") { @@ -6501,7 +6833,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } status, _ := cmd.Flags().GetInt("status") return callMCPToolOnServer("im", "update_group_settings", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "settingKey": mustGetFlag(cmd, "setting-key"), "status": status, }) @@ -6530,15 +6862,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "更新指定群聊的一项设置开关", UseWhen: []string{"需要调整 searchable、入群验证或群权限等设置时"}, AvoidWhen: []string{"全员禁言和成员禁言使用专门的 mute 命令"}, - Examples: []string{"dws chat group update-settings --group --setting-key searchable --status 1"}, + Examples: []string{"dws chat group update-settings --conversation-id --setting-key searchable --status 1"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupUpdateSettingsCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupUpdateSettingsCmd.MarkFlagRequired("group") + chatGroupUpdateSettingsCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupUpdateSettingsCmd.MarkFlagRequired("conversation-id") chatGroupUpdateSettingsCmd.Flags().String("setting-key", "", "群设置项 key (必填)") _ = chatGroupUpdateSettingsCmd.MarkFlagRequired("setting-key") chatGroupUpdateSettingsCmd.Flags().Int("status", 0, "设置值: 0=关闭, 1=开启 (必填)") @@ -6661,14 +6993,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (openDingTalkId 可通过 dws contact user search --query "姓名" 获取)`, - Example: ` dws chat message forward --src-conversation-id --msg-id --dest-conversation-id `, + Example: ` dws chat message forward --src-conversation-id --message-id --dest-conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-id", "dest-conversation-id"); err != nil { + if err := validateRequiredFlags(cmd, "src-conversation-id", "dest-conversation-id"); err != nil { + return err + } + if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } toolArgs := map[string]any{ "srcOpenCid": mustGetFlag(cmd, "src-conversation-id"), - "srcOpenMessageId": mustGetFlag(cmd, "msg-id"), + "srcOpenMessageId": flagOrFallback(cmd, "message-id", "msg-id"), "destOpenCid": mustGetFlag(cmd, "dest-conversation-id"), } if v, _ := cmd.Flags().GetString("uuid"); v != "" { @@ -6700,19 +7035,19 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "把一条已有消息转发到另一个会话", UseWhen: []string{"已知源消息与源、目标会话 ID 时"}, AvoidWhen: []string{"合并转发多条消息时使用 chat message combine-forward"}, - Examples: []string{"dws chat message forward --src-conversation-id --msg-id --dest-conversation-id "}, + Examples: []string{"dws chat message forward --src-conversation-id --message-id --dest-conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "dest-conversation-id", Property: "destOpenCid"}, - {Name: "msg-id", Property: "srcOpenMessageId"}, + {Name: "message-id", Property: "srcOpenMessageId"}, {Name: "src-conversation-id", Property: "srcOpenCid"}, }, }, }) chatMessageForwardCmd.Flags().String("src-conversation-id", "", "源会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageForwardCmd.MarkFlagRequired("src-conversation-id") - chatMessageForwardCmd.Flags().String("msg-id", "", "源消息 openMessageId (必填)") - _ = chatMessageForwardCmd.MarkFlagRequired("msg-id") + chatMessageForwardCmd.Flags().String("message-id", "", "源消息 openMessageId (必填)") + _ = chatMessageForwardCmd.MarkFlagRequired("message-id") chatMessageForwardCmd.Flags().String("dest-conversation-id", "", "目标会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageForwardCmd.MarkFlagRequired("dest-conversation-id") chatMessageForwardCmd.Flags().String("uuid", "", "幂等键(可选)") @@ -6782,13 +7117,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Short: "查询群用户禁言配置", Long: `查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。 返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。`, - Example: ` dws chat group get-mute-config --group `, + Example: ` dws chat group get-mute-config --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group"); err != nil { return err } return callMCPToolOnServer("im", "get_group_mute_config", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group"), }) }, } @@ -6815,11 +7150,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "查询群用户禁言配置(禁言黑名单/全员禁言白名单)", UseWhen: []string{"用户说 看下群里谁被禁言/禁言配置"}, AvoidWhen: []string{"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"}, - Examples: []string{"dws chat group get-mute-config --group --format json"}, + Examples: []string{"dws chat group get-mute-config --conversation-id --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "group", Required: boolPtr(true)}, }, }, }) - chatGroupGetMuteConfigCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + chatGroupGetMuteConfigCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + chatGroupGetMuteConfigCmd.Flags().String("group", "", "--conversation-id 的别名") chatGroupCmd.AddCommand(chatGroupGetMuteConfigCmd) // ── group-mute: 全员禁言 ─────────────────────────────── @@ -6828,13 +7167,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "group-mute", Short: "全员禁言 / 取消全员禁言", Long: `设置或取消群全员禁言。默认开启全员禁言,传 --off 则取消。`, - Example: ` dws chat group-mute --group - dws chat group-mute --group --off + Example: ` dws chat group-mute --conversation-id + dws chat group-mute --conversation-id --off # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if groupID == "" { - return fmt.Errorf("flag --group is required\n hint: dws chat group-mute --group ") + return fmt.Errorf("flag --group is required\n hint: dws chat group-mute --conversation-id ") } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "set_group_mute", map[string]any{ @@ -6866,17 +7205,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "开启或关闭群聊全员禁言", UseWhen: []string{"需要控制整个群的发言权限时"}, AvoidWhen: []string{"只禁言指定成员时使用 chat group-mute-member"}, - Examples: []string{"dws chat group-mute --group "}, + Examples: []string{"dws chat group-mute --conversation-id "}, }, Parameters: []contract.ParamDecl{ + {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "mute", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId"}, }, }, }) - chatGroupMuteCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - chatGroupMuteCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatGroupMuteCmd.Flags().MarkHidden("conversation-id") + chatGroupMuteCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + chatGroupMuteCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatGroupMuteCmd.Flags().MarkHidden("group") chatGroupMuteCmd.Flags().String("id", "", "--group 的别名") _ = chatGroupMuteCmd.Flags().MarkHidden("id") chatGroupMuteCmd.Flags().String("chat", "", "--group 的别名") @@ -6891,15 +7230,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Long: `将指定群成员加入或移出禁言名单。 --mute-time 禁言时长(毫秒),仅支持 5min(300000) / 1h(3600000) / 1d(86400000) / 7d(604800000) / 30d(2592000000)。 默认加入禁言名单,传 --off 则移除。`, - Example: ` dws chat group-mute-member --group --users userId1,userId2 --mute-time 3600000 - dws chat group-mute-member --group --user userId1 --mute-time 3600000 - dws chat group-mute-member --group --user userId1,userId2 --off + Example: ` dws chat group-mute-member --conversation-id --users userId1,userId2 --mute-time 3600000 + dws chat group-mute-member --conversation-id --user userId1 --mute-time 3600000 + dws chat group-mute-member --conversation-id --user userId1,userId2 --off # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if groupID == "" { - return fmt.Errorf("flag --group is required\n hint: dws chat group-mute-member --group --user --mute-time ") + return fmt.Errorf("flag --group is required\n hint: dws chat group-mute-member --conversation-id --user --mute-time ") } usersRaw := flagOrFallback(cmd, "users", "user", "userId") if usersRaw == "" { @@ -6961,18 +7300,18 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "禁言或解除禁言指定群成员", UseWhen: []string{"需要按成员设置禁言时长或解除禁言时"}, AvoidWhen: []string{"需要全员禁言时使用 chat group-mute"}, - Examples: []string{"dws chat group-mute-member --group --users userId1,userId2 --mute-time 3600000"}, + Examples: []string{"dws chat group-mute-member --conversation-id --users userId1,userId2 --mute-time 3600000"}, }, Parameters: []contract.ParamDecl{ + {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "mute", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId"}, {Name: "users", Property: "openDingTalkIds"}, }, }, }) - chatGroupMuteMemberCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - chatGroupMuteMemberCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatGroupMuteMemberCmd.Flags().MarkHidden("conversation-id") + chatGroupMuteMemberCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + chatGroupMuteMemberCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatGroupMuteMemberCmd.Flags().MarkHidden("group") chatGroupMuteMemberCmd.Flags().String("id", "", "--group 的别名") _ = chatGroupMuteMemberCmd.Flags().MarkHidden("id") chatGroupMuteMemberCmd.Flags().String("chat", "", "--group 的别名") @@ -6990,13 +7329,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "set-admin", Short: "设置 / 取消群管理员", Long: `将指定群成员设置为管理员或取消管理员身份。默认设为管理员,传 --off 则取消。`, - Example: ` dws chat group set-admin --group --users userId1,userId2 - dws chat group set-admin --group --user userId1 - dws chat group set-admin --group --user userId1 --off + Example: ` dws chat group set-admin --conversation-id --users userId1,userId2 + dws chat group set-admin --conversation-id --user userId1 + dws chat group set-admin --conversation-id --user userId1 --off # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } usersRaw := flagOrFallback(cmd, "users", "user", "userId") @@ -7006,7 +7345,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 userIDs, openDingTalkIDs := splitChatIDValues(parseCSVValues(usersRaw)) off, _ := cmd.Flags().GetBool("off") toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "admin": !off, } if len(userIDs) > 0 { @@ -7041,17 +7380,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "设置或取消群管理员角色", UseWhen: []string{"需要变更指定成员的群管理员身份时"}, AvoidWhen: []string{"自定义业务角色应使用 chat group-role 系列命令"}, - Examples: []string{"dws chat group set-admin --group --users userId1,userId2"}, + Examples: []string{"dws chat group set-admin --conversation-id --users userId1,userId2"}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "admin", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId"}, {Name: "users", Property: "openDingTalkIds"}, }, }, }) - chatGroupSetAdminCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupSetAdminCmd.MarkFlagRequired("group") + chatGroupSetAdminCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupSetAdminCmd.MarkFlagRequired("conversation-id") chatGroupSetAdminCmd.Flags().String("users", "", "成员 userId 列表,逗号分隔(批量)") chatGroupSetAdminCmd.Flags().String("user", "", "成员 userId,支持逗号分隔") chatGroupSetAdminCmd.Flags().String("userId", "", "--user 的别名") @@ -7084,30 +7423,31 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 _ = chatCategoryRenameCmd.MarkFlagRequired("title") // category add-conv flags - chatCategoryAddConvCmd.Flags().String("group", "", "会话 openConversationId (必填)") - chatCategoryAddConvCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatCategoryAddConvCmd.Flags().MarkHidden("conversation-id") + chatCategoryAddConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") + chatCategoryAddConvCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatCategoryAddConvCmd.Flags().MarkHidden("group") chatCategoryAddConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryAddConvCmd.Flags().MarkHidden("id") chatCategoryAddConvCmd.Flags().String("category-ids", "", "目标分组 ID 列表,逗号分隔 (必填)") _ = chatCategoryAddConvCmd.MarkFlagRequired("category-ids") // category remove-conv flags - chatCategoryRemoveConvCmd.Flags().String("group", "", "会话 openConversationId (必填)") - chatCategoryRemoveConvCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatCategoryRemoveConvCmd.Flags().MarkHidden("conversation-id") + chatCategoryRemoveConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") + chatCategoryRemoveConvCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatCategoryRemoveConvCmd.Flags().MarkHidden("group") chatCategoryRemoveConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryRemoveConvCmd.Flags().MarkHidden("id") chatCategoryRemoveConvCmd.Flags().String("category-ids", "", "目标分组 ID 列表,逗号分隔 (必填)") _ = chatCategoryRemoveConvCmd.MarkFlagRequired("category-ids") // category list-by-conv flags - chatCategoryListByConvCmd.Flags().String("group", "", "会话 openConversationId (必填)") - chatCategoryListByConvCmd.Flags().String("conversation-id", "", "--group 的别名") - _ = chatCategoryListByConvCmd.Flags().MarkHidden("conversation-id") + chatCategoryListByConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") + _ = chatCategoryListByConvCmd.MarkFlagRequired("conversation-id") + chatCategoryListByConvCmd.Flags().String("group", "", "--conversation-id 的别名") + _ = chatCategoryListByConvCmd.Flags().MarkHidden("group") chatCategoryListByConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryListByConvCmd.Flags().MarkHidden("id") - cli.AnnotateRuntimeRequiredFlags(chatCategoryListByConvCmd, "group") + cli.AnnotateRuntimeRequiredFlags(chatCategoryListByConvCmd, "conversation-id") // category batch-info flags chatCategoryBatchInfoCmd.Flags().String("category-ids", "", "分组 ID 列表,逗号分隔 (必填)") @@ -7120,12 +7460,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleListCmd := &cobra.Command{ Use: "list", Short: "拉取会话的群身份列表", - Example: ` dws chat group-role list --group + Example: ` dws chat group-role list --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - groupID := flagOrFallback(cmd, "group", "conversation-id", "id") + groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { - return fmt.Errorf("flag --group is required\n hint: dws chat group-role list --group ") + return fmt.Errorf("flag --group is required\n hint: dws chat group-role list --conversation-id ") } return callMCPToolOnServer("im", "list_custom_group_roles", map[string]any{ "openConversationId": groupID, @@ -7155,26 +7495,26 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "列出群聊中的自定义角色", UseWhen: []string{"需要取得角色 ID 或查看角色定义时"}, AvoidWhen: []string{"查询某个成员已分配角色时使用 chat group-role query-user"}, - Examples: []string{"dws chat group-role list --group "}, + Examples: []string{"dws chat group-role list --conversation-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupRoleListCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleListCmd.MarkFlagRequired("group") + chatGroupRoleListCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleListCmd.MarkFlagRequired("conversation-id") chatGroupRoleAddCmd := &cobra.Command{ Use: "add", Short: "添加群身份", - Example: ` dws chat group-role add --group --name "管理员"`, + Example: ` dws chat group-role add --conversation-id --name "管理员"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "name"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "name"); err != nil { return err } return callMCPToolOnServer("im", "add_custom_group_role", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "name": mustGetFlag(cmd, "name"), }) }, @@ -7202,28 +7542,28 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "在群聊中创建自定义角色", UseWhen: []string{"需要新增可分配给群成员的业务角色时"}, AvoidWhen: []string{"设置系统管理员角色时使用 chat group set-admin"}, - Examples: []string{"dws chat group-role add --group --name \"值班负责人\""}, + Examples: []string{"dws chat group-role add --conversation-id --name \"值班负责人\""}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupRoleAddCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleAddCmd.MarkFlagRequired("group") + chatGroupRoleAddCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleAddCmd.MarkFlagRequired("conversation-id") chatGroupRoleAddCmd.Flags().String("name", "", "群身份名称 (必填)") _ = chatGroupRoleAddCmd.MarkFlagRequired("name") chatGroupRoleUpdateCmd := &cobra.Command{ Use: "update", Short: "更新群身份名称", - Example: ` dws chat group-role update --group --role-id --name "新名称"`, + Example: ` dws chat group-role update --conversation-id --role-id --name "新名称"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "role-id", "name"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "role-id", "name"); err != nil { return err } return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleId": mustGetFlag(cmd, "role-id"), "name": mustGetFlag(cmd, "name"), }) @@ -7252,16 +7592,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "更新群聊自定义角色的名称", UseWhen: []string{"已知角色 ID 并需要重命名该角色时"}, AvoidWhen: []string{"需要变更成员角色分配时使用 set-user 或 remove-user"}, - Examples: []string{"dws chat group-role update --group --role-id --name \"新名称\""}, + Examples: []string{"dws chat group-role update --conversation-id --role-id --name \"新名称\""}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-id", Property: "openRoleId"}, }, }, }) - chatGroupRoleUpdateCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleUpdateCmd.MarkFlagRequired("group") + chatGroupRoleUpdateCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleUpdateCmd.MarkFlagRequired("conversation-id") chatGroupRoleUpdateCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") _ = chatGroupRoleUpdateCmd.MarkFlagRequired("role-id") chatGroupRoleUpdateCmd.Flags().String("name", "", "群身份新名称 (必填)") @@ -7270,13 +7610,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleRemoveCmd := &cobra.Command{ Use: "remove", Short: "删除群身份", - Example: ` dws chat group-role remove --group --role-id `, + Example: ` dws chat group-role remove --conversation-id --role-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "role-id"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "role-id"); err != nil { return err } return callMCPToolOnServer("im", "remove_custom_group_role", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleId": mustGetFlag(cmd, "role-id"), }) }, @@ -7304,27 +7644,27 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "删除群聊中的自定义角色", UseWhen: []string{"明确要移除整个自定义角色定义时"}, AvoidWhen: []string{"只取消某个成员的角色时使用 chat group-role remove-user"}, - Examples: []string{"dws chat group-role remove --group --role-id "}, + Examples: []string{"dws chat group-role remove --conversation-id --role-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-id", Property: "openRoleId"}, }, }, }) - chatGroupRoleRemoveCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleRemoveCmd.MarkFlagRequired("group") + chatGroupRoleRemoveCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleRemoveCmd.MarkFlagRequired("conversation-id") chatGroupRoleRemoveCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") _ = chatGroupRoleRemoveCmd.MarkFlagRequired("role-id") chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", - Example: ` dws chat group-role set-user --group --user --role-ids roleId1,roleId2 + Example: ` dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2 # 查询人员: dws contact user search --keyword "姓名" --format json - # 查询 role-id: dws chat group-role list --group `, + # 查询 role-id: dws chat group-role list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { @@ -7333,7 +7673,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleIds": roleIDs, } if isOpenDingTalkID(user) { @@ -7367,17 +7707,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "为指定群成员设置自定义角色", UseWhen: []string{"需要把一个或多个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, - Examples: []string{"dws chat group-role set-user --group --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-ids", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) - chatGroupRoleSetUserCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleSetUserCmd.MarkFlagRequired("group") + chatGroupRoleSetUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleSetUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") @@ -7387,9 +7727,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", Short: "移除用户的指定群身份", - Example: ` dws chat group-role remove-user --group --user --role-ids roleId1,roleId2`, + Example: ` dws chat group-role remove-user --conversation-id --user --role-ids roleId1,roleId2`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { @@ -7398,7 +7738,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleIds": roleIDs, } if isOpenDingTalkID(user) { @@ -7432,17 +7772,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "取消指定成员的一个或多个自定义角色", UseWhen: []string{"需要保留角色定义但解除成员角色时"}, AvoidWhen: []string{"删除角色定义本身时使用 chat group-role remove"}, - Examples: []string{"dws chat group-role remove-user --group --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role remove-user --conversation-id --user --role-ids roleId1,roleId2"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-ids", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) - chatGroupRoleRemoveUserCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleRemoveUserCmd.MarkFlagRequired("group") + chatGroupRoleRemoveUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleRemoveUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleRemoveUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleRemoveUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleRemoveUserCmd.Flags().MarkHidden("userId") @@ -7452,9 +7792,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleQueryUserCmd := &cobra.Command{ Use: "query-user", Short: "查询群成员的群身份", - Example: ` dws chat group-role query-user --group --user `, + Example: ` dws chat group-role query-user --conversation-id --user `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { @@ -7462,7 +7802,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if isOpenDingTalkID(user) { toolArgs["openDingTalkId"] = user @@ -7495,16 +7835,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "查询指定群成员的自定义角色", UseWhen: []string{"需要核对某个成员在群内的业务角色时"}, AvoidWhen: []string{"列出全部角色定义时使用 chat group-role list"}, - Examples: []string{"dws chat group-role query-user --group --user "}, + Examples: []string{"dws chat group-role query-user --conversation-id --user "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) - chatGroupRoleQueryUserCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupRoleQueryUserCmd.MarkFlagRequired("group") + chatGroupRoleQueryUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupRoleQueryUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleQueryUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleQueryUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleQueryUserCmd.Flags().MarkHidden("userId") @@ -7706,14 +8046,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "dismiss", Short: "解散群聊", Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`, - Example: ` dws chat group dismiss --group + Example: ` dws chat group dismiss --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "dismiss_group", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), }) }, } @@ -7747,15 +8087,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 "仍需保留群供他人继续使用时不要解散", "目标群未确认或用户未明确同意不可恢复后果时不要执行", }, - Examples: []string{"dws chat group dismiss --group "}, + Examples: []string{"dws chat group dismiss --conversation-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupDismissCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupDismissCmd.MarkFlagRequired("group") + chatGroupDismissCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupDismissCmd.MarkFlagRequired("conversation-id") chatGroupSetHistoryCmd := &cobra.Command{ Use: "set-history", @@ -7764,11 +8104,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 FORBIDDEN 禁止查看历史消息 RECENT_100 可查看最近 100 条消息 ALL 可查看全部历史消息`, - Example: ` dws chat group set-history --group --option RECENT_100 - dws chat group set-history --group --option FORBIDDEN + Example: ` dws chat group set-history --conversation-id --option RECENT_100 + dws chat group set-history --conversation-id --option FORBIDDEN # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "option"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "option"); err != nil { return err } option := mustGetFlag(cmd, "option") @@ -7778,7 +8118,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 return fmt.Errorf("--option must be one of FORBIDDEN | RECENT_100 | ALL, got %q", option) } return callMCPToolOnServer("im", "update_show_history_msg_option", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "option": option, }) }, @@ -7806,15 +8146,15 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "设置新成员可见的群历史消息范围", UseWhen: []string{"需要调整新成员入群后的历史消息可见性时"}, AvoidWhen: []string{"普通消息查询或群设置的其他开关不要使用"}, - Examples: []string{"dws chat group set-history --group --option RECENT_100"}, + Examples: []string{"dws chat group set-history --conversation-id --option RECENT_100"}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId"}, + {Name: "conversation-id", Property: "openConversationId"}, }, }, }) - chatGroupSetHistoryCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupSetHistoryCmd.MarkFlagRequired("group") + chatGroupSetHistoryCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupSetHistoryCmd.MarkFlagRequired("conversation-id") chatGroupSetHistoryCmd.Flags().String("option", "", "可见范围: FORBIDDEN | RECENT_100 | ALL (必填)") _ = chatGroupSetHistoryCmd.MarkFlagRequired("option") @@ -7964,14 +8304,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, - Example: ` dws chat message set-pin-msg --open-conversation-id --msg-id `, + Example: ` dws chat message set-pin-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "open-conversation-id", "msg-id"); err != nil { + if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { + return err + } + if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "set_pin_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } @@ -7998,17 +8341,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "把指定消息设为会话置顶消息", UseWhen: []string{"需要在会话中置顶一条已知消息时"}, AvoidWhen: []string{"取消置顶使用 chat message unset-pin-msg"}, - Examples: []string{"dws chat message set-pin-msg --open-conversation-id --msg-id "}, + Examples: []string{"dws chat message set-pin-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "msg-id", Property: "openMessageId"}, + {Name: "message-id", Property: "openMessageId"}, }, }, }) chatMessageSetPinCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageSetPinCmd.MarkFlagRequired("open-conversation-id") - chatMessageSetPinCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageSetPinCmd.MarkFlagRequired("msg-id") + chatMessageSetPinCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageSetPinCmd.MarkFlagRequired("message-id") chatMessageUnsetPinCmd := &cobra.Command{ Use: "unset-pin-msg", @@ -8018,14 +8361,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, - Example: ` dws chat message unset-pin-msg --open-conversation-id --msg-id `, + Example: ` dws chat message unset-pin-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "open-conversation-id", "msg-id"); err != nil { + if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { + return err + } + if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "unset_pin_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } @@ -8052,17 +8398,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 AgentSummary: "取消指定消息的会话置顶", UseWhen: []string{"需要移除一条已知置顶消息时"}, AvoidWhen: []string{"新增置顶使用 chat message set-pin-msg"}, - Examples: []string{"dws chat message unset-pin-msg --open-conversation-id --msg-id "}, + Examples: []string{"dws chat message unset-pin-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "msg-id", Property: "openMessageId"}, + {Name: "message-id", Property: "openMessageId"}, }, }, }) chatMessageUnsetPinCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageUnsetPinCmd.MarkFlagRequired("open-conversation-id") - chatMessageUnsetPinCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageUnsetPinCmd.MarkFlagRequired("msg-id") + chatMessageUnsetPinCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageUnsetPinCmd.MarkFlagRequired("message-id") chatMessageListPinCmd := &cobra.Command{ Use: "list-pin-msg", @@ -8467,11 +8813,14 @@ status 可选值: AuditIgnore — 忽略(服务端拒绝,不可用) AuditRefuse — 拒绝(服务端拒绝,不可用) AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`, - Example: ` dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditApprove - dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditDelete --description "不符合入群条件" + Example: ` dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditApprove + dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditDelete --description "不符合入群条件" # 查询入群验证记录: dws chat group list-join-validations`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "record-id", "applicant", "inviter", "status"); err != nil { + if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group"); err != nil { + return err + } + if err := validateRequiredFlags(cmd, "record-id", "applicant", "inviter", "status"); err != nil { return err } recordID, err := strconv.ParseInt(mustGetFlag(cmd, "record-id"), 10, 64) @@ -8483,7 +8832,7 @@ status 可选值: return fmt.Errorf("unsupported audit status %q, must be one of: AuditApprove, AuditDelete", status) } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group"), "applyRecordId": recordID, "applicantUid": mustGetFlag(cmd, "applicant"), "inviterUid": mustGetFlag(cmd, "inviter"), @@ -8495,8 +8844,13 @@ status 可选值: return callMCPToolOnServer("im", "audit_join_group", toolArgs) }, } - chatGroupAuditJoinValidationCmd.Flags().String("group", "", "群 openConversationId (必填)") - _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("group") + corecmd.RegisterFlags(chatGroupAuditJoinValidationCmd, []LeafFlag{{ + Name: "conversation-id", + Usage: "群 openConversationId (必填)", + Required: true, + Aliases: []string{"group"}, + }}) + _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("conversation-id") chatGroupAuditJoinValidationCmd.Flags().String("record-id", "", "申请记录 ID (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id") chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)") @@ -8529,12 +8883,12 @@ status 可选值: AgentSummary: "审批入群验证记录", UseWhen: []string{"需要对已知入群申请记录执行通过或删除审批动作时"}, AvoidWhen: []string{"还没有 record-id 时先用 chat group list-join-validations 查询"}, - Examples: []string{"dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditApprove"}, + Examples: []string{"dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditApprove"}, }, Parameters: []contract.ParamDecl{ {Name: "applicant", Property: "applicantUid", Required: boolPtr(true)}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "description", Property: "auditDescription", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "inviter", Property: "inviterUid", Required: boolPtr(true)}, {Name: "record-id", Property: "applyRecordId", Required: boolPtr(true), InterfaceType: "integer"}, {Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete"}}, @@ -8845,7 +9199,7 @@ status 可选值: - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id 如何获取 openMessageId: - - dws chat message list --group --time "2025-03-01 00:00:00"`, + - dws chat message list --conversation-id --time "2025-03-01 00:00:00"`, Example: ` dws chat mark-read --conversation-id --message-id dws chat mark-read --id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { @@ -8853,12 +9207,13 @@ status 可选值: if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mark-read --conversation-id --message-id ") } - if err := validateRequiredFlags(cmd, "message-id"); err != nil { + messageID, err := chatMessageID(cmd) + if err != nil { return err } return callMCPToolOnServer("im", "mark_message_read", map[string]any{ "openConversationId": convID, - "openMessageId": mustGetFlag(cmd, "message-id"), + "openMessageId": messageID, }) }, } @@ -8913,21 +9268,24 @@ status 可选值: 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, - Example: ` dws chat message set-top-msg --open-conversation-id --msg-id `, + Example: ` dws chat message set-top-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "open-conversation-id", "msg-id"); err != nil { + if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { + return err + } + if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "set_top_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } chatMessageSetTopMsgCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageSetTopMsgCmd.MarkFlagRequired("open-conversation-id") - chatMessageSetTopMsgCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageSetTopMsgCmd.MarkFlagRequired("msg-id") + chatMessageSetTopMsgCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageSetTopMsgCmd.MarkFlagRequired("message-id") DeclareLeafMetadata(chatMessageSetTopMsgCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -8951,10 +9309,10 @@ status 可选值: AgentSummary: "将指定会话消息设置为置顶", UseWhen: []string{"需要把群聊或单聊中的某条消息置顶展示时"}, AvoidWhen: []string{"取消消息置顶时使用 chat message unset-top-msg"}, - Examples: []string{"dws chat message set-top-msg --open-conversation-id --msg-id "}, + Examples: []string{"dws chat message set-top-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "msg-id", Property: "openMessageId", Required: boolPtr(true)}, + {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(true)}, }, }, @@ -8970,21 +9328,24 @@ status 可选值: 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, - Example: ` dws chat message unset-top-msg --open-conversation-id --msg-id `, + Example: ` dws chat message unset-top-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "open-conversation-id", "msg-id"); err != nil { + if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { + return err + } + if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "unset_top_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), - "openMessageId": mustGetFlag(cmd, "msg-id"), + "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } chatMessageUnsetTopMsgCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("open-conversation-id") - chatMessageUnsetTopMsgCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)") - _ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("msg-id") + chatMessageUnsetTopMsgCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") + _ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("message-id") DeclareLeafMetadata(chatMessageUnsetTopMsgCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -9008,10 +9369,10 @@ status 可选值: AgentSummary: "取消指定会话消息的置顶", UseWhen: []string{"需要取消某条消息的置顶状态时"}, AvoidWhen: []string{"设置消息置顶时使用 chat message set-top-msg"}, - Examples: []string{"dws chat message unset-top-msg --open-conversation-id --msg-id "}, + Examples: []string{"dws chat message unset-top-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "msg-id", Property: "openMessageId", Required: boolPtr(true)}, + {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(true)}, }, }, @@ -9025,17 +9386,17 @@ status 可选值: Use: "update-nick", Short: "设置或清除用户在群内的群昵称", Long: `设置当前用户在指定群聊内的个人群昵称。不传 --nick 时清除当前群昵称。`, - Example: ` dws chat group update-nick --group --nick "我的群昵称" - dws chat group update-nick --group + Example: ` dws chat group update-nick --conversation-id --nick "我的群昵称" + dws chat group update-nick --conversation-id # 不传 --nick 表示清除群昵称 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } nick, _ := cmd.Flags().GetString("nick") return callMCPToolOnServer("im", "update_group_nick", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "nick": nick, }) }, @@ -9064,18 +9425,18 @@ status 可选值: UseWhen: []string{"用户要求修改自己的群昵称,或明确要求清除群昵称"}, AvoidWhen: []string{"修改群名称应使用 chat group rename;修改其他成员信息不应使用本命令"}, Examples: []string{ - "dws chat group update-nick --group --nick \"项目昵称\"", - "dws chat group update-nick --group ", + "dws chat group update-nick --conversation-id --nick \"项目昵称\"", + "dws chat group update-nick --conversation-id ", }, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "nick", Property: "nick", Required: boolPtr(false)}, }, }, }) - chatGroupUpdateNickCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupUpdateNickCmd.MarkFlagRequired("group") + chatGroupUpdateNickCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupUpdateNickCmd.MarkFlagRequired("conversation-id") chatGroupUpdateNickCmd.Flags().String("nick", "", "个人群昵称,不传则清除群昵称") // ── group update-alias: 设置群备注 ────────────────────────── @@ -9084,20 +9445,20 @@ status 可选值: Use: "update-alias", Short: "设置群备注", Long: `设置当前用户对指定群聊的备注名称(仅自己可见)。`, - Example: ` dws chat group update-alias --group --alias-title "项目A群" + Example: ` dws chat group update-alias --conversation-id --alias-title "项目A群" # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "alias-title"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "alias-title"); err != nil { return err } return callMCPToolOnServer("im", "update_user_group_alias", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "aliasTitle": mustGetFlag(cmd, "alias-title"), }) }, } - chatGroupUpdateAliasCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupUpdateAliasCmd.MarkFlagRequired("group") + chatGroupUpdateAliasCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupUpdateAliasCmd.MarkFlagRequired("conversation-id") chatGroupUpdateAliasCmd.Flags().String("alias-title", "", "群备注标题 (必填)") _ = chatGroupUpdateAliasCmd.MarkFlagRequired("alias-title") DeclareLeafMetadata(chatGroupUpdateAliasCmd, LeafSpec{ @@ -9123,11 +9484,11 @@ status 可选值: AgentSummary: "设置当前用户可见的群备注名称", UseWhen: []string{"用户要求给指定群设置仅自己可见的备注名时"}, AvoidWhen: []string{"修改群公开名称应使用 chat group rename"}, - Examples: []string{"dws chat group update-alias --group --alias-title \"项目A群\""}, + Examples: []string{"dws chat group update-alias --conversation-id --alias-title \"项目A群\""}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "alias-title", Property: "aliasTitle", Required: boolPtr(true)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, }, }, }) @@ -9377,16 +9738,16 @@ status 可选值: Long: `在指定群聊中发布群公告,正文为 Markdown 格式。 支持标题、加粗、斜体、删除线、行内代码、链接、代码块、有序/无序/任务列表、表格、引用、分割线、图片、段落、换行。 定时发布:传 --run-at 指定执行时间点,ISO-8601 格式(建议带时区偏移,不带时按北京时区处理)。`, - Example: ` dws chat group notice create --group --content "今晚 22 点系统维护,请提前保存工作内容" - dws chat group notice create --group --content "# 重要通知\n请大家查收" --sticky --send-ding - dws chat group notice create --group --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" + Example: ` dws chat group notice create --conversation-id --content "今晚 22 点系统维护,请提前保存工作内容" + dws chat group notice create --conversation-id --content "# 重要通知\n请大家查收" --sticky --send-ding + dws chat group notice create --conversation-id --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "content"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "content"); err != nil { return err } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "content": mustGetFlag(cmd, "content"), } if v, _ := cmd.Flags().GetBool("sticky"); v { @@ -9402,8 +9763,8 @@ status 可选值: return callMCPToolOnServer("im", "create_group_notice", toolArgs) }, } - chatGroupNoticeCreateCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupNoticeCreateCmd.MarkFlagRequired("group") + chatGroupNoticeCreateCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeCreateCmd.MarkFlagRequired("conversation-id") chatGroupNoticeCreateCmd.Flags().String("content", "", "公告正文,Markdown 格式 (必填)") _ = chatGroupNoticeCreateCmd.MarkFlagRequired("content") chatGroupNoticeCreateCmd.Flags().Bool("sticky", false, "是否吊顶置顶(默认 false)") @@ -9432,11 +9793,11 @@ status 可选值: AgentSummary: "在指定群聊中发布群公告", UseWhen: []string{"需要在群聊里发布 Markdown 群公告,可选吊顶、DING 或定时发布时"}, AvoidWhen: []string{"修改已有群公告时使用 chat group notice edit"}, - Examples: []string{"dws chat group notice create --group --content \"今晚维护\""}, + Examples: []string{"dws chat group notice create --conversation-id --content \"今晚维护\""}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "content", Property: "content", Required: boolPtr(true)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "run-at", Property: "runAtText", Required: boolPtr(false)}, {Name: "send-ding", Property: "sendDing", Required: boolPtr(false)}, {Name: "sticky", Property: "sticky", Required: boolPtr(false)}, @@ -9448,15 +9809,15 @@ status 可选值: Use: "edit", Short: "修改群公告", Long: `修改指定群聊中的群公告,正文为 Markdown 格式,会整体替换原公告内容。`, - Example: ` dws chat group notice edit --group --notice-id --content "更新后的公告内容" - dws chat group notice edit --group --notice-id --content "更新后的公告内容" --sticky --send-ding - # 查询公告 ID: dws chat group notice list --group `, + Example: ` dws chat group notice edit --conversation-id --notice-id --content "更新后的公告内容" + dws chat group notice edit --conversation-id --notice-id --content "更新后的公告内容" --sticky --send-ding + # 查询公告 ID: dws chat group notice list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "notice-id", "content"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "notice-id", "content"); err != nil { return err } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "dataId": mustGetFlag(cmd, "notice-id"), "content": mustGetFlag(cmd, "content"), } @@ -9469,8 +9830,8 @@ status 可选值: return callMCPToolOnServer("im", "edit_group_notice", toolArgs) }, } - chatGroupNoticeEditCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupNoticeEditCmd.MarkFlagRequired("group") + chatGroupNoticeEditCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeEditCmd.MarkFlagRequired("conversation-id") chatGroupNoticeEditCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") _ = chatGroupNoticeEditCmd.MarkFlagRequired("notice-id") chatGroupNoticeEditCmd.Flags().String("content", "", "公告新正文,Markdown 格式 (必填)") @@ -9500,11 +9861,11 @@ status 可选值: AgentSummary: "修改指定群聊中的群公告", UseWhen: []string{"已有公告 dataId,需要替换群公告正文或调整吊顶/DING 状态时"}, AvoidWhen: []string{"发布新公告时使用 chat group notice create"}, - Examples: []string{"dws chat group notice edit --group --notice-id --content \"更新后的公告\""}, + Examples: []string{"dws chat group notice edit --conversation-id --notice-id --content \"更新后的公告\""}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "content", Property: "content", Required: boolPtr(true)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "notice-id", Property: "dataId", Required: boolPtr(true)}, {Name: "send-ding", Property: "sendDing", Required: boolPtr(false)}, {Name: "sticky", Property: "sticky", Required: boolPtr(false)}, @@ -9516,20 +9877,20 @@ status 可选值: Use: "get", Short: "查看群公告详情", Long: `查看指定群公告的详情,包含正文摘要、吊顶状态、发布者、已读人数、点赞/评论数等信息。`, - Example: ` dws chat group notice get --group --notice-id - # 查询公告 ID: dws chat group notice list --group `, + Example: ` dws chat group notice get --conversation-id --notice-id + # 查询公告 ID: dws chat group notice list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id", "notice-id"); err != nil { return err } return callMCPToolOnServer("im", "get_group_notice", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "dataId": mustGetFlag(cmd, "notice-id"), }) }, } - chatGroupNoticeGetCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupNoticeGetCmd.MarkFlagRequired("group") + chatGroupNoticeGetCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeGetCmd.MarkFlagRequired("conversation-id") chatGroupNoticeGetCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") _ = chatGroupNoticeGetCmd.MarkFlagRequired("notice-id") DeclareLeafMetadata(chatGroupNoticeGetCmd, LeafSpec{ @@ -9555,10 +9916,10 @@ status 可选值: AgentSummary: "查看指定群公告详情", UseWhen: []string{"已有群公告 dataId,需要查看公告详情、状态或互动统计时"}, AvoidWhen: []string{"不知道公告 ID 时先使用 chat group notice list"}, - Examples: []string{"dws chat group notice get --group --notice-id "}, + Examples: []string{"dws chat group notice get --conversation-id --notice-id "}, }, Parameters: []contract.ParamDecl{ - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "notice-id", Property: "dataId", Required: boolPtr(true)}, }, }, @@ -9569,16 +9930,16 @@ status 可选值: Short: "查看群公告列表", Long: `分页查看指定群聊的群公告列表。默认查询已发布公告,传 --scheduled 查询定时公告列表。 支持游标分页,hasMore=true 时用返回的 nextPageCursor 作为下次 --cursor。`, - Example: ` dws chat group notice list --group - dws chat group notice list --group --limit 20 --cursor - dws chat group notice list --group --scheduled + Example: ` dws chat group notice list --conversation-id + dws chat group notice list --conversation-id --limit 20 --cursor + dws chat group notice list --conversation-id --scheduled # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if v, _ := cmd.Flags().GetInt("limit"); v > 0 { toolArgs["limit"] = v @@ -9592,8 +9953,8 @@ status 可选值: return callMCPToolOnServer("im", "list_group_notices", toolArgs) }, } - chatGroupNoticeListCmd.Flags().String("group", "", "群聊 openConversationId (必填)") - _ = chatGroupNoticeListCmd.MarkFlagRequired("group") + chatGroupNoticeListCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeListCmd.MarkFlagRequired("conversation-id") chatGroupNoticeListCmd.Flags().Int("limit", 10, "每页返回数量(默认 10,最大 100)") chatGroupNoticeListCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextPageCursor)") chatGroupNoticeListCmd.Flags().Bool("scheduled", false, "是否查询定时公告列表(默认 false,查询已发布公告)") @@ -9620,11 +9981,11 @@ status 可选值: AgentSummary: "分页查看指定群聊的群公告列表", UseWhen: []string{"需要列出群公告、查找公告 dataId 或翻页查看定时公告时"}, AvoidWhen: []string{"已知 dataId 并只看详情时使用 chat group notice get"}, - Examples: []string{"dws chat group notice list --group --limit 20"}, + Examples: []string{"dws chat group notice list --conversation-id --limit 20"}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "cursor", Property: "cursor", Required: boolPtr(false)}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "limit", Property: "limit", Required: boolPtr(false)}, {Name: "scheduled", Property: "scheduled", Required: boolPtr(false)}, }, @@ -9698,15 +10059,15 @@ status 可选值: 本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。 该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`, - Example: ` dws chat group upgrade-to-external --group --dry-run - dws chat group upgrade-to-external --group --extension '{"source":"dws"}' --dry-run + Example: ` dws chat group upgrade-to-external --conversation-id --dry-run + dws chat group upgrade-to-external --conversation-id --extension '{"source":"dws"}' --dry-run # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "group"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if rawExtension := mustGetFlag(cmd, "extension"); rawExtension != "" { var rawValues map[string]any @@ -9752,16 +10113,16 @@ status 可选值: AgentSummary: "不可逆地把已有普通群升级为外部群", UseWhen: []string{"群主明确要求保留现有会话并升级为可跨组织协作的外部群,且已确认不可逆影响"}, AvoidWhen: []string{"新建外部群应使用 chat group create --type EXTERNAL;未确认群主身份和不可逆影响时不要执行"}, - Examples: []string{"dws chat group upgrade-to-external --group "}, + Examples: []string{"dws chat group upgrade-to-external --conversation-id "}, }, Parameters: []contract.ParamDecl{ + {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "extension", Property: "extension", Required: boolPtr(false), InterfaceType: "object"}, - {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, }, }, }) - chatGroupUpgradeToExternalCmd.Flags().String("group", "", "待升级普通群的 openConversationId (必填)") - _ = chatGroupUpgradeToExternalCmd.MarkFlagRequired("group") + chatGroupUpgradeToExternalCmd.Flags().String("conversation-id", "", "待升级普通群的 openConversationId (必填)") + _ = chatGroupUpgradeToExternalCmd.MarkFlagRequired("conversation-id") chatGroupUpgradeToExternalCmd.Flags().String("extension", "", `预留扩展字段 JSON 对象 (可选),如 '{"source":"dws"}'`) chatCategoryCreateSmartCmd := &cobra.Command{ @@ -10080,7 +10441,8 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, // the supported nested commands. The chat root's "im" alias makes these // compatibility hints available through both chat and im. root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send")) - root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group ")) + root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --conversation-id ")) + installChatIMIDFlagAliases(root) return root } diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index a5f0bacd..118cc983 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -3,6 +3,7 @@ package helpers import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" ) func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error { @@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error { t.Helper() - command, _, err := newChatCommand().Find(path) + InitDeps(&scriptedToolCaller{}) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + root := newChatCommand() + installExampleGlobalFlags(root) + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + command, _, err := root.Find(path) if err != nil { return err } for name, value := range flags { - if err := command.Flags().Set(name, value); err != nil { + flag := command.Flag(name) + if flag == nil { + return fmt.Errorf("no such flag -%s", name) + } + if err := flag.Value.Set(value); err != nil { return err } + flag.Changed = true } return command.RunE(command, nil) } @@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes hint string }{ {path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, - {path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, + {path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id "}, } { command, remaining, err := root.Find([]string{tc.path}) if err != nil { @@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes } } +func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) { + restoreChatManifestExternalVisibleFlags(nil) + + mismatchedRoot := &cobra.Command{Use: "chat"} + mismatchedRoot.AddCommand(&cobra.Command{Use: "other"}) + restoreChatManifestExternalVisibleFlags(mismatchedRoot) + + missingPrimary := &cobra.Command{Use: "leaf"} + installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID) + if flag := missingPrimary.Flags().Lookup("group"); flag != nil { + t.Fatalf("alias registered without canonical flag: %#v", flag) + } + + skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + skipGroup.Flags().String("conversation-id", "", "") + skipGroup.Flags().String("group-name", "", "") + installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID) + if flag := skipGroup.Flags().Lookup("group"); flag != nil { + t.Fatalf("group alias registered beside group-name: %#v", flag) + } + if flag := skipGroup.Flags().Lookup("chat"); flag == nil { + t.Fatal("non-group alias was not registered") + } + + preRunCalled := false + withPreRun := &cobra.Command{ + Use: "leaf", + PreRunE: func(cmd *cobra.Command, args []string) error { + preRunCalled = true + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + withPreRun.Flags().String("conversation-id", "", "") + installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID) + withPreRun.SetArgs([]string{"--group", "cid"}) + if err := withPreRun.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute with alias and previous PreRunE: %v", err) + } + if !preRunCalled { + t.Fatal("previous PreRunE was not called") + } +} + +func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) { + caller := &productExampleCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + root := newChatCommand() + command, _, err := root.Find([]string{"message", "forward"}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"message-id", "msg-id"} { + if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil { + delete(flag.Annotations, cobra.BashCompOneRequiredFlag) + } + } + if err := command.Flags().Set("src-conversation-id", "src"); err != nil { + t.Fatal(err) + } + if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil { + t.Fatal(err) + } + err = command.RunE(command, nil) + if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") { + t.Fatalf("forward missing message id error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -199,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"category", "remove-conv", "--group=cid", "--category-ids=1,2"}, {"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"}, {"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"}, + {"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"}, {"group", "update-icon", "--group=cid", "--icon-media-id=@valid"}, {"group", "set-history", "--group=cid", "--option=ALL"}, - {"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, + {"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, {"mark-read", "--conversation-id=cid", "--message-id=mid"}, {"text", "translate", "--query=hello", "--to=zh_CN"}, {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"}, @@ -293,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) { caller := &scriptedToolCaller{} err := runChatCoverageCommand(t, caller, "message", "send-card", - "--group=cid", + "--conversation-id=cid", "--at-open-dingtalk-ids=D1,D2,D1", "--at-all", ) @@ -314,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) { name string args []string }{ - {name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}}, - {name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}}, + {name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}}, + {name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}}, } { t.Run(tc.name, func(t *testing.T) { caller := &scriptedToolCaller{} err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...) - if err == nil || !strings.Contains(err.Error(), "only supported with --group") { + if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") { t.Fatalf("error = %v, want group-only mention validation", err) } if caller.calls != 0 { @@ -330,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) { } } +func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + for _, tc := range []struct { + name string + args []string + want map[string]any + }{ + { + name: "group alias", + args: []string{"--group=cid"}, + want: map[string]any{"openConversationId": "cid"}, + }, + { + name: "receiver alias", + args: []string{"--receiver=DAAAAAAAAAAAiE"}, + want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...) + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) { + t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + for _, tc := range []struct { + name string + flag string + }{ + {name: "canonical conversation-id", flag: "--conversation-id=cid"}, + {name: "hidden group alias", flag: "--group=cid"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group", "audit-join-validation", + tc.flag, + "--record-id=123", + "--applicant=D-applicant", + "--inviter=D-inviter", + "--status=AuditDelete", + "--description=deny", + ) + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "openConversationId": "cid", + "applyRecordId": int64(123), + "applicantUid": "D-applicant", + "inviterUid": "D-inviter", + "status": "AuditDelete", + "auditDescription": "deny", + } + if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) { + t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want) + } + }) + } +} + +func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + path []string + flag map[string]string + want string + }{ + {name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"}, + {name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"}, + {name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"}, + {name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"}, + {name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"}, + {name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"}, + {name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"}, + {name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"}, + {name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"}, + {name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"}, + {name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"}, + {name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"}, + {name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"}, + {name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"}, + {name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"}, + {name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"}, + {name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"}, + {name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"}, + {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"}, + {name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"}, + {name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"}, + {name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"}, + {name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"}, + {name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"}, + {name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"}, + {name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"}, + {name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"}, + {name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"}, + {name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"}, + {name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"}, + {name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"}, + {name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"}, + {name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"}, + } + + probe := newChatCommand() + messageList, _, err := probe.Find([]string{"message", "list"}) + if err != nil { + t.Fatal(err) + } + if got, err := chatConversationID(messageList); err != nil || got != "" { + t.Fatalf("empty chatConversationID = %q, %v", got, err) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := runChatCoverageDirect(t, tc.path, tc.flag) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) { + for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} { + t.Run(alias, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil { + t.Fatal(err) + } + if caller.server != "im" || caller.tool != "query_msg_read_status" { + t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool) + } + if got := caller.args["openConversationId"]; got != "cid-1" { + t.Fatalf("openConversationId = %#v, want cid-1", got) + } + if got := caller.args["openMessageId"]; got != "msg-1" { + t.Fatalf("openMessageId = %#v, want msg-1", got) + } + }) + } +} + func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -343,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1") _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1") _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1") + _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1") _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1") oldGet := httpGetFile diff --git a/internal/helpers/chat_message_help_test.go b/internal/helpers/chat_message_help_test.go index 9e528d3c..30f299df 100644 --- a/internal/helpers/chat_message_help_test.go +++ b/internal/helpers/chat_message_help_test.go @@ -17,8 +17,88 @@ import ( "bytes" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" + "github.com/spf13/cobra" ) +func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) { + cmd := newChatCommand() + leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"}) + if err != nil { + t.Fatal(err) + } + canonical := leaf.Flags().Lookup("conversation-id") + if canonical == nil || canonical.Hidden { + t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical) + } + legacy := leaf.Flags().Lookup("group") + if legacy == nil || !legacy.Hidden { + t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy) + } + if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" { + t.Fatalf("group alias_of annotation = %#v", got) + } + if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 { + t.Fatalf("group alias_origin annotation = %#v", got) + } + if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 { + t.Fatalf("hidden group alias kept required annotation: %#v", got) + } +} + +func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) { + restoreChatGroupBotsLegacyRequired(nil) + restoreChatPendingMigrationCanonicalRequired(nil) + root := &cobra.Command{Use: "chat"} + root.AddCommand(&cobra.Command{Use: "other"}) + restoreChatGroupBotsLegacyRequired(root) + restoreChatPendingMigrationCanonicalRequired(root) +} + +func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) { + cmd := newChatCommand() + leaf, _, err := cmd.Find([]string{"group", "bots"}) + if err != nil { + t.Fatal(err) + } + group := leaf.Flags().Lookup("group") + if group == nil || group.Hidden { + t.Fatalf("group flag = %#v, want visible legacy flag", group) + } + if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" { + t.Fatalf("group required annotation = %#v, want true", got) + } + if leaf.Flags().Lookup("conversation-id") != nil { + t.Fatalf("chat group bots still exposes migrated --conversation-id") + } + if leaf.Flags().Lookup("group-name") != nil { + t.Fatalf("chat group bots still exposes migrated --group-name") + } +} + +func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) { + cmd := newChatCommand() + leaf, _, err := cmd.Find([]string{"group", "dismiss"}) + if err != nil { + t.Fatal(err) + } + canonical := leaf.Flags().Lookup("conversation-id") + if canonical == nil { + t.Fatal("missing conversation-id flag") + } + if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" { + t.Fatalf("conversation-id required annotation = %#v, want true", got) + } + legacy := leaf.Flags().Lookup("group") + if legacy == nil || !legacy.Hidden { + t.Fatalf("group flag = %#v, want hidden legacy alias", legacy) + } + if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" { + t.Fatalf("group alias_of annotation = %#v", got) + } +} + func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) { tests := []struct { name string @@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing contains: []string{ "send -> query-send-status -> edit", "query-send-status --open-task-id <上一步返回的openTaskId>", - "edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>", + "edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>", }, notContain: "chat message list", }, @@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing contains: []string{ "send -> query-send-status -> recall", "query-send-status --open-task-id <上一步返回的openTaskId>", - "recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>", + "recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>", }, notContain: "chat message list", }, @@ -183,3 +263,91 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te }) } } + +func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) { + for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} { + t.Run(command, func(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs([]string{"message", command, "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String()) + } + + help := output.String() + if !strings.Contains(help, "--conversation-id") { + t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help) + } + for _, visible := range []string{"--group", "--id", "--chat"} { + if !strings.Contains(help, visible+" string") { + t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help) + } + } + }) + } +} + +func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs([]string{"group", "bots", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat group bots --help: %v\n%s", err, output.String()) + } + + help := output.String() + if !strings.Contains(help, "--group string") { + t.Fatalf("chat group bots help missing visible --group:\n%s", help) + } + for _, hidden := range []string{"--conversation-id", "--group-name"} { + if strings.Contains(help, hidden) { + t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help) + } + } +} + +func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs([]string{"message", "send-card", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat message send-card --help: %v\n%s", err, output.String()) + } + + help := output.String() + for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} { + if !strings.Contains(help, visible) { + t.Fatalf("send-card help missing %s:\n%s", visible, help) + } + } + for _, visible := range []string{"--group", "--receiver"} { + if !strings.Contains(help, visible+" string") { + t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help) + } + } +} + +func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs([]string{"group", "audit-join-validation", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String()) + } + + help := output.String() + if !strings.Contains(help, "--conversation-id") { + t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help) + } + if strings.Contains(help, "--group string") { + t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help) + } +} diff --git a/internal/helpers/chat_ports_coverage_test.go b/internal/helpers/chat_ports_coverage_test.go index 4466a966..025e31f9 100644 --- a/internal/helpers/chat_ports_coverage_test.go +++ b/internal/helpers/chat_ports_coverage_test.go @@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) { }, }, }, + { + name: "open-conversation-id alias", + args: []string{ + "message", "update-text-emotion", + "--open-conversation-id", "conv-3", + "--message-id", "msg-3", + "--old-emotion-id", "old-3", + "--emotion-id", "new-3", + "--emotion-name", "smile", + "--text", "done", + "--background-id", "im_bg_2", + }, + want: guardedMutationCall{ + productID: "im", + toolName: "update_text_emotion", + args: map[string]any{ + "openConversationId": "conv-3", + "openMsgId": "msg-3", + "oldEmotionId": "old-3", + "emotionId": "new-3", + "emotionName": "smile", + "text": "done", + "backgroundId": "im_bg_2", + }, + }, + }, } for _, test := range tests { test := test @@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) { { 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", + wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)", }, { name: "missing old-emotion-id", @@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) { wantErr: `required flag(s) "old-emotion-id" not set`, }, { - name: "missing msg-id and background-id", + name: "missing message-id and background-id", args: []string{ "message", "update-text-emotion", "--conversation-id", "conv-1", @@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) { "--emotion-name", "like", "--text", "nice", }, - wantErr: `required flag(s) "background-id", "msg-id" not set`, + wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)", }, } for _, test := range tests { @@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) { } 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) + for _, test := range []struct { + name string + args []string + want guardedMutationCall + }{ + { + name: "legacy group alias", + args: []string{"group", "get-mute-config", "--group", "conv-1"}, + want: guardedMutationCall{ + productID: "im", + toolName: "get_group_mute_config", + args: map[string]any{"openConversationId": "conv-1"}, + }, + }, + { + name: "canonical conversation id", + args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"}, + want: guardedMutationCall{ + productID: "im", + toolName: "get_group_mute_config", + args: map[string]any{"openConversationId": "conv-2"}, + }, + }, + } { + test := test + t.Run(test.name, func(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...) + if err != nil { + t.Fatalf("get-mute-config returned error: %v", err) + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) { + t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want) + } + }) } } @@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T) } } -func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) { +func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(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 err == nil || !strings.Contains(err.Error(), "--conversation-id") { + t.Fatalf("err = %v, want message containing --conversation-id", err) } if len(caller.calls) != 0 { t.Fatalf("tool calls = %#v, want none", caller.calls) diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index da4509ab..08243328 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command { driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)") driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)") driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)") - driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)") + driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)") driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)") driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)") driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)") diff --git a/internal/helpers/drive_depth.go b/internal/helpers/drive_depth.go index 09432f7c..a10c28bd 100644 --- a/internal/helpers/drive_depth.go +++ b/internal/helpers/drive_depth.go @@ -10,11 +10,14 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" "time" "github.com/fatih/color" "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" ) // ────────────────────────────────────────────────────────── @@ -296,6 +299,11 @@ bfs: if folderErr != nil { if driveDepthUnrecoverable(folderErr) { + if latest > 0 { + // 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial, + // 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。 + return folderErr + } // partial 照吐 stdout,错误详情走 stderr,非零退出 errs = append(errs, newDriveDepthError(folder, folderErr)) if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil { @@ -331,17 +339,206 @@ bfs: } } - if truncated && latest > 0 { - return &CLIError{ - Code: CodeContentTruncated, - Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest), - Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest), - } + // BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件, + // 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。 + if latest > 0 && (truncated || len(errs) > 0) { + return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)) } return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter) } +// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。 +// +// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N —— +// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder +// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。 +type driveLatestScope struct { + // domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。 + domain string + // filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。 + filters string + // folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。 + folder string + // depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。 + depth int + // notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够); + // Windows 构建下含元字符的值走这里,命令里只留占位符。 + notes []string + // 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户 + // 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然 + // 含 * 与中文,裸拼接会改变命令解析。 +} + +// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell +// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台 +// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。 +type driveLatestValueRenderer func(string) (string, bool) + +// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符 +// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。 +// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。 +func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string { + if inline, ok := render(v); ok { + return inline + } + s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v))) + return driveLatestUnsafeValuePlaceholder +} + +// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。 +const driveLatestUnsafeValuePlaceholder = "<见下方原值>" + +// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与 +// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id")); +// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。 +// +// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL, +// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。 +func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope { + return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue) +} + +// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态 +// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略, +// 从而在单一平台上覆盖两种形态。 +func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope { + scope := driveLatestScope{depth: depth} + if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" { + scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID) + } else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" { + scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID) + } + if rootFolder != "" { + scope.folder = scope.value(render, "--folder", rootFolder) + } + // 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。 + filters := make([]string, 0, 4) + for _, name := range []string{"pattern", "type", "start", "end"} { + if v, _ := cmd.Flags().GetString(name); v != "" { + filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v)) + } + } + scope.filters = strings.Join(filters, " ") + return scope +} + +// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。 +// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。 +func (s driveLatestScope) command(folderArg string) string { + parts := make([]string, 0, 4) + parts = append(parts, "dws drive list") + if s.domain != "" { + parts = append(parts, s.domain) + } + if folderArg != "" { + parts = append(parts, "--folder "+folderArg) + } + if s.filters != "" { + parts = append(parts, s.filters) + } + return strings.Join(parts, " ") +} + +// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用 +// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」; +// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里, +// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。 +func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error { + // 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限, + // 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。 + causes := make([]string, 0, 2) + if len(errs) > 0 { + causes = append(causes, driveLatestFolderFailureCause(errs)) + } + if truncated { + causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems)) + } + return &CLIError{ + Code: CodeContentTruncated, + Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", + strings.Join(causes, ";同时 "), latest), + Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope), + } +} + +// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。 +// folderName 空回落 folderID,两者都空回落 。 +// +// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定, +// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是 +// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。 +func driveLatestFolderFailureCause(errs []driveDepthError) string { + first := errs[0] + folder := first.FolderName + if folder == "" { + folder = first.FolderID + } + if folder == "" { + folder = "" + } + return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)", + len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason, + driveLatestSafeRemoteText(first.Message)) +} + +// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。 +// +// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的 +// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口 +// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON, +// 不受影响,也不该受影响(消费方需要原始数据)。 +// +// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与 +// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。 +func driveLatestSafeRemoteText(s string) string { + s = output.SanitizeForTerminal(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\t", " ") + return strings.TrimSpace(s) +} + +// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条: +// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域; +// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest, +// 否则照抄复现同一错误。 +func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string { + // 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由 + // command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。 + narrowed := scope.command("<可读子目录ID>") + clauses := make([]string, 0, 3) + if folderFailed { + clauses = append(clauses, "确认目录权限后重试") + } + switch { + case folderFailed && truncated: + // 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。 + clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest)) + case folderFailed: + clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest)) + default: + clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest)) + } + // partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无 + // errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令, + // 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。 + if folderFailed && scope.depth > 1 { + clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth)) + } + // Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以 + // 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。 + if len(scope.notes) > 0 { + clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s", + driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、"))) + } + return strings.Join(clauses, ";") +} + +// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。 +// +// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走): +// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而 +// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。 func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error { if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil { return err @@ -389,6 +586,14 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca maxDepth = d } } + // sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间 + // 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、 + // SIGINT 取消、unrecoverable partial。 + // depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约, + // stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。 + for _, item := range items { + delete(item, "sortTime") + } if (latest > 0 || filter.active()) && reqDepth == 1 { stripDriveDepthDecorations(items) } diff --git a/internal/helpers/drive_latest_incomplete_test.go b/internal/helpers/drive_latest_incomplete_test.go new file mode 100644 index 00000000..f2894056 --- /dev/null +++ b/internal/helpers/drive_latest_incomplete_test.go @@ -0,0 +1,731 @@ +package helpers + +import ( + "errors" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go: +// +// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约; +// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。 +// +// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层 +// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。 +// +// 另锁定评审反馈的三个边界: +// +// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉; +// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域; +// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。 +// +// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁 +// scripts/policy/run-platform-coverage-gate.sh 只跑 +// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的 +// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败 +// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。 + +// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。 +func assertNoSortTime(t *testing.T, result map[string]any) { + t.Helper() + items, ok := result["items"].([]any) + if !ok { + t.Fatalf("items missing or wrong type: %#v", result["items"]) + } + for i, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + t.Fatalf("item[%d] not an object: %#v", i, raw) + } + if _, leaked := item["sortTime"]; leaked { + t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item) + } + } +} + +// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的 +// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime, +// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。 +func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) { + twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}` + + // 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。 + t.Run("depth_latest", func(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil { + t.Fatalf("runDriveListDepth: %v", err) + } + assertNoSortTime(t, decodeDepthResult(t, out)) + }) + + // 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。 + t.Run("depth_no_latest", func(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil { + t.Fatalf("runDriveListDepth: %v", err) + } + assertNoSortTime(t, decodeDepthResult(t, out)) + }) + + // 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter), + // strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。 + t.Run("depth_filter_no_latest", func(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil { + t.Fatalf("runDriveListDepth: %v", err) + } + result := decodeDepthResult(t, out) + if len(result["items"].([]any)) != 2 { + t.Fatalf("filter 应保留两个 FILE: %#v", result["items"]) + } + assertNoSortTime(t, result) + }) +} + +// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事: +// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime; +// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改** +// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。 +// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例 +// test_sigint_exits_130_with_partial 提供本地对照。 +func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) { + caller := &scriptedToolCaller{} + out := installDepthCaller(t, caller) + items := []map[string]any{ + {"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2}, + } + errs := []driveDepthError{ + {Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"}, + } + // latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。 + err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{}) + var cancelErr *driveDepthCancelledError + if !errors.As(err, &cancelErr) { + t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) + } + if cancelErr.ExitCode() != 130 { + t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode()) + } + result := decodeDepthResult(t, out) + // partial 契约不变:items 与 errors 都照吐,truncated 标记为真。 + if len(result["items"].([]any)) != 1 { + t.Fatalf("取消路径应保留 partial items: %#v", result["items"]) + } + if len(result["errors"].([]any)) != 1 { + t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"]) + } + if result["truncated"] != true { + t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"]) + } + assertNoSortTime(t, result) +} + +// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business, +// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。 +// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* → +// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil); +// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。 +func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`}, + {text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}) + if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") { + t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err) + } + // 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。 + if out.Len() != 0 { + t.Fatalf("expected no stdout on refusal, got: %s", out.String()) + } +} + +// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达: +// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断, +// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied +// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。 +func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) { + useDriveDepthArgs(t) + var bulk strings.Builder + bulk.WriteString(`{"items":[`) + for i := 0; i < driveDepthMaxItems; i++ { + if i > 0 { + bulk.WriteString(",") + } + fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i) + } + bulk.WriteString(`]}`) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`}, + {text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[] + {text: bulk.String()}, // dirB:撞 2000 上限 → truncated + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated { + t.Fatalf("err = %T %v, want CodeContentTruncated", err, err) + } + msg := cliErr.Message + if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") { + t.Fatalf("组合场景缺 TRUNCATED token: %q", msg) + } + // 旧实现在这里丢掉整段目录失败详情。 + if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || + !strings.Contains(msg, "folder=报表") || + !strings.Contains(msg, "permission_denied") { + t.Fatalf("组合场景丢失目录失败详情: %q", msg) + } + if out.Len() != 0 { + t.Fatalf("expected no stdout on refusal, got: %s", out.String()) + } +} + +// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器 +// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。 +func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`}, + {text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, + }} + installDepthCaller(t, caller) + cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{}) + var cliErr *CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("err = %T %v", err, err) + } + // 恢复命令必须带原 --space-id,且给出原 --depth 层数。 + assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7") + if !strings.Contains(cliErr.Suggestion, "--depth 4") { + t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion) + } +} + +// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误 +// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。 +// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial +// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新, +// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。 +func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`}, + {text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`}, + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}) + // 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。 + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired { + t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err) + } + if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") { + t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message) + } + // 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。 + if out.Len() != 0 { + t.Fatalf("expected no stdout on refusal, got: %s", out.String()) + } +} + +// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。 +func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) { + // 纯截断分支:errs 为空 → 只有 TRUNCATED。 + t.Run("truncated_only", func(t *testing.T) { + err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3}) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated { + t.Fatalf("err = %T %v, want CodeContentTruncated", err, err) + } + if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") { + t.Fatalf("message = %q", cliErr.Message) + } + if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") { + t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message) + } + assertDriveLatestSuggestion(t, cliErr.Suggestion, "") + }) + + // 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。 + t.Run("folder_failure_only", func(t *testing.T) { + err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3}) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated { + t.Fatalf("err = %T %v, want CodeContentTruncated", err, err) + } + msg := cliErr.Message + if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || + !strings.Contains(msg, "报表") || + !strings.Contains(msg, "depth=2") || + !strings.Contains(msg, "permission_denied") || + !strings.Contains(msg, "2 个目录未读全") { + t.Fatalf("message = %q", msg) + } + if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") { + t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg) + } + assertDriveLatestSuggestion(t, cliErr.Suggestion, "") + }) + + // 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、 + // permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。 + t.Run("truncated_with_folder_failures", func(t *testing.T) { + err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3}) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated { + t.Fatalf("err = %T %v, want CodeContentTruncated", err, err) + } + msg := cliErr.Message + // 两个成因都要可被消费方 token 匹配到。 + if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") { + t.Fatalf("组合场景需同时带两个 token: %q", msg) + } + // 目录失败详情必须完整保留——这是用户唯一能动手修的线索。 + if !strings.Contains(msg, "报表") || + !strings.Contains(msg, "depth=2") || + !strings.Contains(msg, "permission_denied") || + !strings.Contains(msg, "2 个目录未读全") { + t.Fatalf("组合场景丢失目录失败详情: %q", msg) + } + if !strings.Contains(msg, "拒绝输出不完整的 Top-4") { + t.Fatalf("message 缺少拒绝产出结论: %q", msg) + } + assertDriveLatestSuggestion(t, cliErr.Suggestion, "") + // 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。 + if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") { + t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion) + } + }) + + // folderName 为空回落 folderID;folderID 也空回落 。 + t.Run("folder_fallback", func(t *testing.T) { + byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2}) + if !strings.Contains(byID.Error(), "folder=fid-x") { + t.Fatalf("fallback to folderID: %v", byID) + } + byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2}) + if !strings.Contains(byRoot.Error(), "folder=") { + t.Fatalf("fallback to : %v", byRoot) + } + }) +} + +// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。 +func twoDriveDepthErrors() []driveDepthError { + return []driveDepthError{ + {Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"}, + {Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"}, + } +} + +// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定 +// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后 +// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份 +// 「看起来对」的 Top-N —— 比直接报错更难发现。 +func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) { + cases := []struct { + name string + flags map[string]string + want string + }{ + // 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。 + {name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"}, + // 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。 + {name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"}, + // 钉盘路由:--space-id 丢了就退回「我的文件」。 + {name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "") + // 三条成因组合下的恢复命令都必须带原查询域。 + for _, variant := range []struct { + label string + truncated bool + errs []driveDepthError + }{ + {"truncated_only", true, nil}, + {"folder_failure_only", false, twoDriveDepthErrors()}, + {"both", true, twoDriveDepthErrors()}, + } { + err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope) + var cliErr *CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("%s: err = %T %v", variant.label, err, err) + } + assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want) + } + }) + } + + // --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id), + // 否则恢复命令会把知识库查询写成钉盘查询。 + t.Run("workspace_wins_over_space_id", func(t *testing.T) { + scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{ + "workspace": "ws-1", "space-id": "sp-7", + }), 3, "") + err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope) + suggestion := err.(*CLIError).Suggestion + if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") { + t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion) + } + }) + + // 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。 + t.Run("no_scope_adds_nothing", func(t *testing.T) { + scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "") + err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope) + suggestion := err.(*CLIError).Suggestion + if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") { + t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion) + } + assertDriveLatestSuggestion(t, suggestion, "") + }) + + // depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立, + // 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。 + t.Run("single_depth_omits_depth_flag", func(t *testing.T) { + err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1}) + suggestion := err.(*CLIError).Suggestion + if strings.Contains(suggestion, "--depth") { + t.Fatalf("单层不应出现 --depth: %q", suggestion) + } + }) + + // 多层时给出确切层数,用户无需把 <原层数> 换成数字。 + t.Run("multi_depth_emits_actual_depth", func(t *testing.T) { + err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4}) + suggestion := err.(*CLIError).Suggestion + if !strings.Contains(suggestion, "--depth 4") { + t.Fatalf("应给出原层数 --depth 4: %q", suggestion) + } + if strings.Contains(suggestion, "<原层数>") { + t.Fatalf("不应残留占位符: %q", suggestion) + } + }) +} + +// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到 +// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时 +// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。 +// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。 +func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) { + cases := []struct { + name string + flag string + value string + want string + }{ + // 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。 + {name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc", + want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`}, + // 命令替换:裸拼接会在用户复制执行时真的跑起来。 + {name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`}, + // 分号拆语句。 + {name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`}, + // 空格拆参:裸拼接会让 --folder 收到错误的值。 + {name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。 + // Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue), + // 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。 + scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue) + err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope) + suggestion := err.(*CLIError).Suggestion + if !strings.Contains(suggestion, tc.want) { + t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion) + } + }) + } +} + +// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2: +// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项, +// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部 +// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。 +func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) { + cmd := newDriveListScopeCmd(t, map[string]string{ + "workspace": "ws-1", + "pattern": "*日报*", + "type": "file", + "start": "7d", + "end": "2026-08-01", + }) + // 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为 + // 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。 + scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue) + err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope) + suggestion := err.(*CLIError).Suggestion + + // 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。 + for _, want := range []string{ + "--workspace ws-1", + "--pattern '*日报*'", + "--type file", + "--start 7d", + "--end 2026-08-01", + } { + for _, clause := range strings.Split(suggestion, ";") { + cmdText := extractTrailingDwsCommand(clause) + if cmdText == "" { + continue + } + if !strings.Contains(cmdText, want) { + t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText) + } + } + } + + // 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符, + // 否则它就不是「原范围」。 + var origin string + for _, clause := range strings.Split(suggestion, ";") { + if strings.Contains(clause, "去掉 --latest") { + origin = extractTrailingDwsCommand(clause) + } + } + if origin == "" { + t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion) + } + if !strings.Contains(origin, "--folder folder-root") { + t.Fatalf("原范围命令应保留原 --folder: %q", origin) + } + if strings.Contains(origin, "<可读子目录ID>") { + t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin) + } +} + +// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空 +// 造 --folder:硬塞一个目录同样是改候选集。 +func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) { + scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "") + err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope) + suggestion := err.(*CLIError).Suggestion + for _, clause := range strings.Split(suggestion, ";") { + if !strings.Contains(clause, "去掉 --latest") { + continue + } + if strings.Contains(extractTrailingDwsCommand(clause), "--folder") { + t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause) + } + } +} + +// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项: +// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传, +// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题, +// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。 +func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) { + assertNoControlChars := func(t *testing.T, msg string) { + t.Helper() + for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} { + if strings.ContainsRune(msg, r) { + t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg) + } + } + } + + // folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。 + t.Run("folder_name", func(t *testing.T) { + err := driveLatestIncompleteError(3, false, []driveDepthError{{ + Depth: 2, + FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m", + Reason: "permission_denied", + Message: "denied\r\n\x1b]0;pwned\x07次行", + }}, driveLatestScope{depth: 2}) + msg := err.(*CLIError).Message + assertNoControlChars(t, msg) + // 清理不能把诊断信息一起抹掉:可读部分必须留下。 + if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") { + t.Fatalf("清理后应保留可读文本: %q", msg) + } + }) + + // folderName 为空时回落到 folderID,该字段同样来自服务端。 + t.Run("folder_id_fallback", func(t *testing.T) { + err := driveLatestIncompleteError(1, true, []driveDepthError{{ + FolderID: "fid\x1b[1m-x", + Reason: "api_error", + Message: "boom", + }}, driveLatestScope{depth: 1}) + msg := err.(*CLIError).Message + assertNoControlChars(t, msg) + if !strings.Contains(msg, "fid-x") { + t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg) + } + }) +} + +// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符 +// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行), +// 首尾空白收掉,可打印内容与中文原样保留。 +func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "plain", in: "denied", want: "denied"}, + {name: "cjk", in: "报表目录", want: "报表目录"}, + {name: "csi", in: "a\x1b[31mb", want: "ab"}, + {name: "osc", in: "a\x1b]0;t\x07b", want: "ab"}, + {name: "newline_to_space", in: "a\nb", want: "a b"}, + {name: "tab_to_space", in: "a\tb", want: "a b"}, + {name: "carriage_return_dropped", in: "a\rb", want: "ab"}, + {name: "trim_outer", in: "\n denied \t", want: "denied"}, + {name: "empty", in: "", want: ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := driveLatestSafeRemoteText(tc.in); got != tc.want { + t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的 +// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe +// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标 +// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。 +// +// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」 +// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会 +// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。 +func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) { + const hostile = "sp-7&whoami" + newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string { + t.Helper() + scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render) + return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion + } + + t.Run("windows_never_inlines", func(t *testing.T) { + suggestion := newSuggestion(t, driveLatestWindowsScopeValue) + for _, clause := range strings.Split(suggestion, ";") { + cmdText := extractTrailingDwsCommand(clause) + if cmdText == "" { + continue + } + // cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。 + if strings.Contains(cmdText, "&") { + t.Fatalf("windows 形态的命令不得含 &: %s", cmdText) + } + if strings.Contains(cmdText, hostile) { + t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText) + } + } + if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) { + t.Fatalf("应降级为占位符: %s", suggestion) + } + if !strings.Contains(suggestion, strconv.Quote(hostile)) { + t.Fatalf("应在展示行给出原值: %s", suggestion) + } + if !strings.Contains(suggestion, "不是可执行命令") { + t.Fatalf("展示行须显式声明非可执行: %s", suggestion) + } + }) + + t.Run("posix_quotes_inline", func(t *testing.T) { + suggestion := newSuggestion(t, driveLatestPosixScopeValue) + if !strings.Contains(suggestion, "'"+hostile+"'") { + t.Fatalf("posix 形态应单引号内联原值: %s", suggestion) + } + // POSIX 下不该无谓降级 —— 那会白白损失可复制体验。 + if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) { + t.Fatalf("posix 形态不应降级为占位符: %s", suggestion) + } + for _, clause := range strings.Split(suggestion, ";") { + cmdText := extractTrailingDwsCommand(clause) + if cmdText == "" { + continue + } + if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") { + t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText) + } + } + }) +} + +// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand +// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。 +func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "list"} + for _, name := range []string{ + "workspace", "workspace-id", "space-id", // 查询域 + "folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令) + "pattern", "type", "start", "end", // 决定候选集的过滤条件 + } { + cmd.Flags().String(name, "", "") + } + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s=%s: %v", name, value, err) + } + } + return cmd +} + +// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束: +// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查); +// 2. 含 --latest 的引导子句存在; +// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。 +func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) { + t.Helper() + clauses := strings.Split(suggestion, ";") + sawLatestGuide := false + for _, clause := range clauses { + cmd := extractTrailingDwsCommand(clause) + if cmd == "" { + continue + } + if wantScope != "" && !strings.Contains(cmd, wantScope) { + t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd) + } + if strings.Contains(clause, "去掉 --latest") { + if strings.Contains(cmd, "--latest") { + t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd) + } + continue + } + if strings.Contains(cmd, "--latest") { + sawLatestGuide = true + } + } + if !sawLatestGuide { + t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion) + } +} + +// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。 +func extractTrailingDwsCommand(clause string) string { + idx := strings.LastIndex(clause, "dws ") + if idx < 0 { + return "" + } + return clause[idx:] +} diff --git a/internal/helpers/drive_latest_scope_posix.go b/internal/helpers/drive_latest_scope_posix.go new file mode 100644 index 00000000..a92ff0d5 --- /dev/null +++ b/internal/helpers/drive_latest_scope_posix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package helpers + +// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段, +// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。 +// +// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。 +// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是 +// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。 +func driveLatestScopeValue(value string) (string, bool) { + return driveLatestPosixScopeValue(value) +} diff --git a/internal/helpers/drive_latest_scope_windows.go b/internal/helpers/drive_latest_scope_windows.go new file mode 100644 index 00000000..f921a456 --- /dev/null +++ b/internal/helpers/drive_latest_scope_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package helpers + +// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段, +// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。 +// +// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号 +// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见 +// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。 +func driveLatestScopeValue(value string) (string, bool) { + return driveLatestWindowsScopeValue(value) +} diff --git a/internal/helpers/shell_quote.go b/internal/helpers/shell_quote.go new file mode 100644 index 00000000..a06f78f5 --- /dev/null +++ b/internal/helpers/shell_quote.go @@ -0,0 +1,85 @@ +package helpers + +import "strings" + +// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。 +// +// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户 +// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令 +// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。 +// +// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号, +// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处 +// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。 +// +// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样 +// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部 +// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法 +// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符: +// +// '\'' +// +// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个 +// flag 吞掉。 +func shellQuoteArg(s string) string { + if s == "" { + return "''" + } + if shellValueIsBare(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空, +// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致, +// 无需引用即可内联进可复制命令。 +func shellValueIsBare(s string) bool { + return s != "" && !strings.ContainsFunc(s, shellNeedsQuote) +} + +// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做 +// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。 +func driveLatestPosixScopeValue(value string) (string, bool) { + return shellQuoteArg(value), true +} + +// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进 +// 命令,由调用方降级为占位符 + 展示行。 +// +// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式: +// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令, +// 粘贴即执行 whoami; +// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开; +// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的 +// 闭合-反斜杠转义-重开写法不兼容。 +// +// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行 +// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。 +func driveLatestWindowsScopeValue(value string) (string, bool) { + if shellValueIsBare(value) { + return value, true + } + return "", false +} + +// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。 +// +// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的 +// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。 +// +// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除: +// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在 +// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与 +// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。 +func shellNeedsQuote(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return false + case r >= 'A' && r <= 'Z': + return false + case r >= '0' && r <= '9': + return false + } + return !strings.ContainsRune("_@+=:,./-", r) +} diff --git a/internal/helpers/shell_quote_roundtrip_unix_test.go b/internal/helpers/shell_quote_roundtrip_unix_test.go new file mode 100644 index 00000000..9afc5213 --- /dev/null +++ b/internal/helpers/shell_quote_roundtrip_unix_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +package helpers + +import ( + "os/exec" + "testing" +) + +// TestCrossPlatformCoverageShellQuoteArgRoundTrip 把引用后的串交给真 /bin/sh 求值,证明它被解析回 +// **恰好一个、且内容完全相同**的参数。上面那条表驱动测试只能证明「我以为的形态」,这条证明 +// 「shell 认的形态」——两者不是一回事,恢复命令是给用户复制到 shell 里跑的,后者才是契约。 +// +// 它同时是注入的负向对照:一旦引用失效,$(id) / `id` 会真的执行、空格与 & 会拆参,argc 或 +// 取回的值必然不等于原值,测试立刻红。用 build tag 排除 Windows(无 POSIX sh),跨平台形态 +// 断言留在 shell_quote_test.go。 +func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skipf("POSIX sh unavailable: %v", err) + } + + values := []string{ + "ws-1", + "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc", + "sp 7", + "a;id", + "a&b", + "a|b", + "$(id)", + "`id`", + "$HOME", + "*.txt", + "a>b", + `a\b`, + `a"b`, + "a!b", + "{a,b}", + "(a)", + "~/x", + "a#b", + "it's", + "'", + "a'b'c", + "", + "报表", + "a\tb", + "a\nb", + "--not-a-flag", + } + + for _, want := range values { + t.Run(want, func(t *testing.T) { + quoted := shellQuoteArg(want) + + // 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。 + // 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。 + argc, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$#"`).Output() + if err != nil { + t.Fatalf("argc probe failed for %q (quoted %s): %v", want, quoted, err) + } + if string(argc) != "1" { + t.Fatalf("argc = %s, want 1 — %q quoted as %s split or vanished", argc, want, quoted) + } + + // 2) 内容逐字节相同:展开、命令替换、转义都不得改动值。 + got, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$1"`).Output() + if err != nil { + t.Fatalf("value probe failed for %q (quoted %s): %v", want, quoted, err) + } + if string(got) != want { + t.Fatalf("round-trip mismatch\n input: %q\n quoted: %s\n shell: %q", want, quoted, got) + } + }) + } +} diff --git a/internal/helpers/shell_quote_test.go b/internal/helpers/shell_quote_test.go new file mode 100644 index 00000000..2dacbf7d --- /dev/null +++ b/internal/helpers/shell_quote_test.go @@ -0,0 +1,185 @@ +package helpers + +import ( + "runtime" + "testing" +) + +// TestCrossPlatformCoverageShellQuoteArg 表驱动锁定 argv 引用规则。分两类断言: +// - 安全值原样返回(保持恢复命令可读,且在 PowerShell/cmd 下同样可复制); +// - 含任何 shell 元字符的值整体单引号包裹,内嵌单引号按 POSIX 标准写法处理 +// (闭合、拼反斜杠转义的单引号、重开)。 +// +// 往返正确性另有 Unix 下用真 sh 求值的对照测试(shell_quote_roundtrip_unix_test.go)。 +// +// TestCrossPlatformCoverage 前缀是门禁约定:平台覆盖率门禁只跑该前缀(与 TestAllShortcuts)的 +// 测试,shell_quote.go 的覆盖全靠这一条,去掉前缀会让 Coverage (macOS)/(Windows) 直接红。 +// 详见 drive_latest_incomplete_test.go 头部说明。 +func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + // —— 无需引用:白名单字符 —— + {name: "plain_id", in: "ws-1", want: "ws-1"}, + {name: "digits", in: "1234567890", want: "1234567890"}, + {name: "upper_lower", in: "abcXYZ", want: "abcXYZ"}, + {name: "all_safe_punct", in: "_@+=:,./-", want: "_@+=:,./-"}, + {name: "path_like", in: "/tmp/a.b/c-d_e", want: "/tmp/a.b/c-d_e"}, + + // —— 必须引用:真实业务形态 —— + // workspace 常见形态就是带查询串的 URL:? 与 & 都不在白名单,& 在裸拼接下会拆命令。 + {name: "url_with_query", in: "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc", + want: `'https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc'`}, + + // —— 必须引用:shell 元字符 —— + {name: "space", in: "sp 7", want: `'sp 7'`}, + {name: "tab", in: "a\tb", want: "'a\tb'"}, + {name: "newline", in: "a\nb", want: "'a\nb'"}, + {name: "semicolon", in: "a;id", want: `'a;id'`}, + {name: "ampersand", in: "a&b", want: `'a&b'`}, + {name: "pipe", in: "a|b", want: `'a|b'`}, + {name: "command_substitution", in: "$(id)", want: `'$(id)'`}, + {name: "backtick", in: "`id`", want: "'`id`'"}, + {name: "variable", in: "$HOME", want: `'$HOME'`}, + // % 不在白名单:cmd.exe 会展开 %VAR%,故含 % 的值一律视为需引用。 + {name: "windows_variable", in: "%PATH%", want: `'%PATH%'`}, + {name: "url_percent_escape", in: "a%20b", want: `'a%20b'`}, + {name: "glob", in: "*.txt", want: `'*.txt'`}, + {name: "redirect", in: "a>b", want: `'a>b'`}, + {name: "backslash", in: `a\b`, want: `'a\b'`}, + {name: "double_quote", in: `a"b`, want: `'a"b'`}, + {name: "history_expansion", in: "a!b", want: `'a!b'`}, + {name: "brace", in: "{a,b}", want: `'{a,b}'`}, + {name: "paren", in: "(a)", want: `'(a)'`}, + {name: "tilde", in: "~/x", want: `'~/x'`}, + {name: "hash", in: "a#b", want: `'a#b'`}, + + // —— 单引号:唯一无法直接放进单引号串的字符 —— + {name: "single_quote", in: "it's", want: `'it'\''s'`}, + {name: "only_single_quote", in: "'", want: `''\'''`}, + {name: "two_single_quotes", in: "a'b'c", want: `'a'\''b'\''c'`}, + + // —— 边界 —— + // 空串必须显式成 '',否则参数会从 argv 里整个消失(--workspace 吞掉下一个 token)。 + {name: "empty", in: "", want: `''`}, + // 非 ASCII 一律引用:保守优于漏字符,中文目录名很常见。 + {name: "cjk", in: "报表", want: `'报表'`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shellQuoteArg(tc.in); got != tc.want { + t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + }) + } +} + +// TestCrossPlatformCoverageDriveLatestScopeValueStrategies 直接测两条平台策略的本体。它们是 +// shell_quote.go 里的纯函数、与构建平台无关,因此**在任一平台都能验证 Windows 侧的行为** —— +// 这一点是刻意设计:真 shell 往返测试只能在 Unix 跑,Windows 的安全属性必须由这里钉住, +// 否则「Windows 上不会把不受信任值放进可执行命令」就成了无人复核的断言。 +func TestCrossPlatformCoverageDriveLatestScopeValueStrategies(t *testing.T) { + // 这些值在至少一种目标 shell 下会改变命令解析。 + hostile := []struct { + name string + value string + }{ + {"cmd_command_separator", "sp-7&whoami"}, // cmd.exe:& 分隔命令,单引号不是引号 + {"cmd_variable", "%PATH%"}, // cmd.exe:无条件展开 + {"workspace_url", "https://x/y?a=1&b=2"}, // 合法 workspace 形态,含 & + {"space", "sp 7"}, + {"pwsh_statement_separator", "a;id"}, // PowerShell:; 分隔语句 + {"posix_substitution", "$(id)"}, + {"backtick", "`id`"}, + {"posix_variable", "$HOME"}, + {"pipe", "a|b"}, + {"redirect", "a>b"}, + {"caret", "a^b"}, // cmd.exe 转义符 + {"single_quote", "it's"}, + {"cjk", "报表"}, + {"empty", ""}, + } + for _, tc := range hostile { + t.Run("hostile/"+tc.name, func(t *testing.T) { + // POSIX:始终可内联,但必须是引用后的形态(不能等于原值)。 + got, ok := driveLatestPosixScopeValue(tc.value) + if !ok { + t.Fatalf("POSIX 策略应始终可内联: %q", tc.value) + } + if got == tc.value { + t.Fatalf("POSIX 策略未引用危险值: %q", tc.value) + } + // Windows:一律拒绝内联 —— 没有对 cmd.exe 与 PowerShell 同时安全的引用形式。 + if inline, ok := driveLatestWindowsScopeValue(tc.value); ok { + t.Fatalf("Windows 策略不得内联危险值 %q(得到 %q)", tc.value, inline) + } + }) + } + + // 安全值:两条策略都原样内联,命令保持可读、跨 shell 可复制。 + for _, v := range []string{"ws-1", "sp-7", "folder-root", "modifyTime", "7d", "2026-08-01", "a.b/c-d_e", "a@b"} { + t.Run("bare/"+v, func(t *testing.T) { + if got, ok := driveLatestPosixScopeValue(v); !ok || got != v { + t.Fatalf("POSIX 应原样内联安全值 %q: got %q ok=%v", v, got, ok) + } + if got, ok := driveLatestWindowsScopeValue(v); !ok || got != v { + t.Fatalf("Windows 应原样内联安全值 %q: got %q ok=%v", v, got, ok) + } + }) + } + + // 空串在 POSIX 下引用成一对空单引号(否则参数会从 argv 消失);Windows 侧归入不可内联。 + if got, ok := driveLatestPosixScopeValue(""); !ok || got != "''" { + t.Fatalf("POSIX 空串应引用成一对空单引号: got %q ok=%v", got, ok) + } +} + +// TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters 锁定「安全值可原样内联」的根本 +// 前提:白名单里不能含任何一种目标 shell 会特殊解释的字符。 +// +// 这比「用某个 shell 跑一遍」更本质 —— 内联路径的安全性不来自引用正确,而来自字符集本身无害; +// 而拒绝内联的路径(Windows 侧)连引用都不需要。三套元字符合并检查:POSIX sh、PowerShell、 +// cmd.exe。`%` 曾因 URL 转义(%20)被误列入白名单,而 cmd.exe 会无条件展开 %VAR%,这条测试 +// 同时是该缺陷的回归锁。 +func TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters(t *testing.T) { + // 逐字符列出,避免用字符串字面量时漏掉转义细节。 + meta := []rune{ + '&', '|', '<', '>', ';', '(', ')', '{', '}', '[', ']', // 分隔/分组 + '$', '`', '"', '\'', '\\', // 引用与替换 + '^', '%', // cmd.exe:转义符与变量展开 + '*', '?', '~', '#', '!', // 通配、家目录、注释、历史/取反 + ' ', '\t', '\n', '\r', // 空白:拆参与换行注入 + } + for _, r := range meta { + if !shellNeedsQuote(r) { + t.Fatalf("shell 元字符 %q 落在「无需引用」白名单内,安全值会被原样内联", string(r)) + } + } + // 反向哨兵:常规标识符字符必须留在白名单内,否则恢复命令会被无谓地全量引用/降级。 + for _, r := range []rune{'a', 'Z', '0', '9', '_', '-', '.', '/', ':', ',', '=', '+', '@'} { + if shellNeedsQuote(r) { + t.Fatalf("常规字符 %q 被判为需引用,会让恢复命令可读性无谓下降", string(r)) + } + } +} + +// TestCrossPlatformCoverageDriveLatestScopeValueBinding 断言当前构建绑定到了正确的策略。 +// 这条测试在每个平台各自成立,把「哪个平台用哪条策略」也纳入 CI(Coverage (Windows) 会跑它)。 +func TestCrossPlatformCoverageDriveLatestScopeValueBinding(t *testing.T) { + const hostile = "sp-7&whoami" + inline, ok := driveLatestScopeValue(hostile) + if runtime.GOOS == "windows" { + if ok { + t.Fatalf("windows 构建不得内联 %q(得到 %q)", hostile, inline) + } + return + } + if !ok { + t.Fatalf("posix 构建应可内联 %q", hostile) + } + if inline != `'sp-7&whoami'` { + t.Fatalf("posix 构建应单引号包裹: %s", inline) + } +} diff --git a/internal/helpers/wukong_weekly_sync_chat_todo_test.go b/internal/helpers/wukong_weekly_sync_chat_todo_test.go index a20a664f..efe5d871 100644 --- a/internal/helpers/wukong_weekly_sync_chat_todo_test.go +++ b/internal/helpers/wukong_weekly_sync_chat_todo_test.go @@ -116,8 +116,8 @@ func requireWukongWeeklySyncConfirmation(t *testing.T, err error) { func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) { caller := &wukongWeeklySyncCaller{} _, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "category", "list-by-conv") - if err == nil || !strings.Contains(err.Error(), "flag --group is required") { - t.Fatalf("missing group error = %v", err) + if err == nil || !strings.Contains(err.Error(), "missing required flag: --conversation-id") { + t.Fatalf("missing conversation ID error = %v", err) } requireWukongWeeklySyncNoCalls(t, caller) @@ -378,15 +378,15 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpdateNickClearSemantics(t *testin if findErr != nil { t.Fatal(findErr) } - if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--group") { - t.Fatalf("direct missing group error = %v", err) + if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") { + t.Fatalf("direct missing conversation-id error = %v", err) } caller := &wukongWeeklySyncCaller{} _, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "group", "update-nick") - if err == nil || !strings.Contains(err.Error(), "group") { - t.Fatalf("missing group error = %v", err) + if err == nil || !strings.Contains(err.Error(), "conversation-id") { + t.Fatalf("missing conversation-id error = %v", err) } requireWukongWeeklySyncNoCalls(t, caller) @@ -428,8 +428,8 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpgradeValidationAndSafety(t *test upgrade.Flags().Bool("yes", false, "") } _ = upgrade.Flags().Set("yes", "true") - if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--group") { - t.Fatalf("direct missing group error = %v", err) + if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") { + t.Fatalf("direct missing conversation-id error = %v", err) } tests := []struct { diff --git a/internal/transport/client.go b/internal/transport/client.go index ddbae10d..73bac675 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -50,14 +50,14 @@ func init() { Name: "DWS_TRUSTED_DOMAINS", Category: configmeta.CategoryNetwork, Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)", - DefaultValue: "*.dingtalk.com", - Example: "*.dingtalk.com,custom.example.com", + DefaultValue: "*.dingtalk.com,*.dingtalk.io", + Example: "*.dingtalk.com,*.dingtalk.io,custom.example.com", }) } const ( trustedDomainsEnv = "DWS_TRUSTED_DOMAINS" - defaultTrustedDomains = "*.dingtalk.com" + defaultTrustedDomains = "*.dingtalk.com,*.dingtalk.io" // defaultHTTPTimeout is the default timeout for HTTP transport requests. defaultHTTPTimeout = 30 * time.Second @@ -654,7 +654,7 @@ func shouldPreserveEndpointQuery(parsed *url.URL) bool { return false } switch strings.ToLower(parsed.Hostname()) { - case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com": + case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io": return true default: return false diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index d9a1b070..782e0006 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "testing" ) @@ -234,6 +235,23 @@ func TestIsEndpointTrusted_DomainMatch(t *testing.T) { } } +func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) { + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "") + t.Setenv("DWS_TRUSTED_DOMAINS", "") + c := NewClient(nil) + if !c.isEndpointTrusted("https://mcp-gw.dingtalk.com/server/contact") { + t.Fatal("default trusted domains should trust dingtalk.com") + } + if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") { + t.Fatal("default trusted domains should trust dingtalk.io") + } + for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} { + if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) { + t.Fatalf("international gateway %q should preserve endpoint query", host) + } + } +} + func TestHttpStatusError(t *testing.T) { t.Parallel() codes := []int{ diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index 509a9a31..4d72256f 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -562,6 +562,16 @@ func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) { endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", want: "https://mcp-gw.dingtalk.com/server/demo?key=secret", }, + { + name: "prepub international gateway", + endpoint: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret#frag", + want: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret", + }, + { + name: "prod international gateway", + endpoint: "https://mcp-gw.dingtalk.io/server/demo?key=secret#frag", + want: "https://mcp-gw.dingtalk.io/server/demo?key=secret", + }, } for _, tt := range cases { diff --git a/pkg/config/constants.go b/pkg/config/constants.go index c986e45a..03b682cf 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -17,6 +17,7 @@ package config import ( + "net/url" "os" "path/filepath" "strings" @@ -165,10 +166,19 @@ const ( // Override at runtime via ~/.dws/mcp_url file. DefaultMCPBaseURL = "https://mcp.dingtalk.com" + // ManagedMCPURLRegionFileName records an MCP URL written automatically by + // login region selection, so a later region change does not delete a user's + // explicit MCP override. + ManagedMCPURLRegionFileName = "mcp_url.login_region" + // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" + // InternationalTerminalBaseURL is the DingTalk international developer + // platform base URL. + InternationalTerminalBaseURL = "https://open-dev.dingtalk.io" + // DeveloperSettingsPath is the path to the organization developer // settings page (CLI access management). DeveloperSettingsPath = "/fe/old#/developerSettings" @@ -201,8 +211,9 @@ func GetMCPBaseURL() string { } // GetTerminalBaseURL returns the terminal base URL with priority: -// 1. ~/.dws/terminal_url file content (for pre-release environment) -// 2. Default value (https://open-dev.dingtalk.com) +// 1. ~/.dws/terminal_url file content (for custom environments) +// 2. International terminal when the configured MCP endpoint uses .io +// 3. Default value (https://open-dev.dingtalk.com) func GetTerminalBaseURL() string { terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url") if data, err := os.ReadFile(terminalURLPath); err == nil { @@ -210,6 +221,12 @@ func GetTerminalBaseURL() string { return u } } + if parsed, err := url.Parse(GetMCPBaseURL()); err == nil { + host := strings.ToLower(parsed.Hostname()) + if host == "dingtalk.io" || strings.HasSuffix(host, ".dingtalk.io") { + return InternationalTerminalBaseURL + } + } return DefaultTerminalBaseURL } diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index c9e538b0..7296b231 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -219,6 +219,34 @@ func TestGetMCPBaseURLUsesConfigFile(t *testing.T) { } } +func TestCrossPlatformCoverageGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + want := "https://open-dev.dingtalk.io/fe/old#/developerSettings" + if got := GetDeveloperSettingsURL(); got != want { + t.Fatalf("GetDeveloperSettingsURL() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "terminal_url"), []byte("https://custom-open-dev.example.com\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(terminal_url) error = %v", err) + } + + if got := GetTerminalBaseURL(); got != "https://custom-open-dev.example.com" { + t.Fatalf("GetTerminalBaseURL() = %q, want explicit terminal URL", got) + } +} + func TestMaxUploadFileSize(t *testing.T) { t.Parallel() var want int64 = 100 * 1024 * 1024 diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index ece90440..7d5ed345 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -31,7 +31,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -97,7 +97,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -130,7 +130,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -163,7 +163,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -196,7 +196,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -229,7 +229,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -262,7 +262,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -295,7 +295,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -328,7 +328,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -361,7 +361,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -394,7 +394,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -427,7 +427,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -460,7 +460,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -493,7 +493,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -526,7 +526,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -559,7 +559,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -592,7 +592,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -625,7 +625,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -658,7 +658,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -691,7 +691,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -724,7 +724,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -757,7 +757,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -790,7 +790,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -823,7 +823,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -856,7 +856,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -892,7 +892,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -925,7 +925,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -991,7 +991,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -1024,7 +1024,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -1057,7 +1057,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -1090,7 +1090,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { @@ -1123,7 +1123,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, { diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 8bed0f87..63f9b30d 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -67,6 +67,12 @@ Flags: - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 +- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中 + 目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以 + 退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集 + (查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下, + 若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell + 没有共同安全的引用形式),照抄时需手动替换。 ### 获取钉盘空间列表 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 8d921447..5da0ba4a 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -61,6 +61,12 @@ Flags: - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 +- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中 + 目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以 + 退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集 + (查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下, + 若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell + 没有共同安全的引用形式),照抄时需手动替换。 ### 获取钉盘空间列表