Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 |
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -416,16 +416,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **204 commands across 16 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **209 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming)
|
||||
`conference` (video meetings)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-3
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -416,16 +416,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **16 个产品,204 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,209 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)
|
||||
`conference`(视频会议)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -310,7 +310,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
|
||||
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
|
||||
@@ -624,7 +624,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
@@ -700,7 +700,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
@@ -855,7 +855,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -918,7 +918,7 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != rawURI {
|
||||
|
||||
@@ -16,21 +16,31 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for storing app credentials.
|
||||
appConfigFile = "app.json"
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
@@ -53,9 +63,14 @@ var (
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file.
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, appConfigFile)
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
@@ -169,10 +169,10 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
strictMin = b.PositionalIndex + 1
|
||||
}
|
||||
}
|
||||
var argsValidator cobra.PositionalArgs = cobra.NoArgs
|
||||
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
|
||||
switch {
|
||||
case totalMax == 0:
|
||||
argsValidator = cobra.NoArgs
|
||||
argsValidator = cobra.ArbitraryArgs
|
||||
case strictMin > 0 && strictMin == totalMax:
|
||||
argsValidator = cobra.MinimumNArgs(strictMin)
|
||||
case strictMin > 0:
|
||||
|
||||
@@ -93,11 +93,11 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
|
||||
单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
|
||||
|
||||
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
|
||||
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
|
||||
Example: ` dws chat message send --group <openconversation_id> --text "hello"
|
||||
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
|
||||
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
|
||||
@@ -128,7 +128,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
|
||||
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
|
||||
cmd.Flags().String("title", "", "消息标题 (单聊必填,群聊可选)")
|
||||
cmd.Flags().String("title", "", "消息标题 (必填,群聊与单聊都必填)")
|
||||
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
@@ -196,10 +196,14 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
|
||||
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
|
||||
}
|
||||
// Direct-message tools (send_direct_message_as_user) reject an empty title at
|
||||
// the API level with a misleading "发群服务窗会话消息失败" error, so fail loudly
|
||||
// here instead. Group messages do not require a title.
|
||||
if (hasUser || hasOpenID) && strings.TrimSpace(title) == "" {
|
||||
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
|
||||
// reject an empty title at the API level with a misleading
|
||||
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
|
||||
// declares title as a required parameter on both tools.
|
||||
if strings.TrimSpace(title) == "" {
|
||||
if hasGroup {
|
||||
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
|
||||
}
|
||||
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"--group", "cid-xyz", "--text", "hello"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "openConversation_id",
|
||||
wantValue: "cid-xyz",
|
||||
@@ -81,7 +81,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "positional-text",
|
||||
args: []string{"--group", "cid-xyz", "hello from positional"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "text",
|
||||
wantValue: "hello from positional",
|
||||
@@ -132,6 +132,11 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
{
|
||||
name: "group-without-title",
|
||||
args: []string{"--group", "cid-x", "--text", "hi"},
|
||||
wantErr: "--title is required for group messages",
|
||||
},
|
||||
{
|
||||
name: "direct-user-without-title",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
|
||||
+20
-5
@@ -74,23 +74,38 @@ const (
|
||||
DefaultPartition = "default/default"
|
||||
)
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
// IsOpenEdition reports whether an edition name maps to the open-source core.
|
||||
//
|
||||
// This helper takes the edition name as a parameter instead of calling
|
||||
// edition.Get() so that pkg/config remains a leaf dependency — importable
|
||||
// from internal/cli, internal/app, internal/cache, etc. without risking
|
||||
// import cycles.
|
||||
func IsOpenEdition(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
return name == "" || name == "open"
|
||||
}
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
func EditionPartition(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "open" {
|
||||
if IsOpenEdition(name) {
|
||||
return DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
}
|
||||
|
||||
// EditionFileName returns the edition-partitioned file name for base+ext.
|
||||
func EditionFileName(name, base, ext string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if IsOpenEdition(name) {
|
||||
return base + ext
|
||||
}
|
||||
return base + "-" + name + ext
|
||||
}
|
||||
|
||||
// ── Auth flow timeouts ──────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -96,6 +96,48 @@ func TestEditionPartition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenEdition(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"empty is open", "", true},
|
||||
{"open is open", "open", true},
|
||||
{"whitespace open is open", " open ", true},
|
||||
{"wukong is sibling", "wukong", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsOpenEdition(tc.input); got != tc.want {
|
||||
t.Fatalf("IsOpenEdition(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"empty uses legacy filename", "", "app.json"},
|
||||
{"open uses legacy filename", "open", "app.json"},
|
||||
{"whitespace is trimmed", " wukong ", "app-wukong.json"},
|
||||
{"sibling is suffixed", "wukong", "app-wukong.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := EditionFileName(tc.edition, "app", ".json"); got != tc.want {
|
||||
t.Fatalf("EditionFileName(%q, \"app\", \".json\") = %q, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTokenExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
if ManualTokenExpiry <= 0 {
|
||||
|
||||
@@ -183,7 +183,7 @@ Flags:
|
||||
|
||||
## message send — 以当前用户身份发消息
|
||||
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
|
||||
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
|
||||
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
|
||||
|
||||
@@ -191,15 +191,15 @@ Flags:
|
||||
Usage:
|
||||
dws chat message send [flags] [<text>]
|
||||
Example:
|
||||
dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
|
||||
dws chat message send --group <openconversation_id> "hello"
|
||||
dws chat message send --group <openconversation_id> --title "通知" "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
|
||||
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --title "图片" --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "图片" --media-id <mediaId>
|
||||
Flags:
|
||||
--text string 消息内容(推荐使用,也可用位置参数)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
@@ -219,6 +219,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- --text 和位置参数二选一,--text 优先
|
||||
- --title 必填(群聊与单聊都必填,API 强制要求)
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
|
||||
@@ -722,7 +723,7 @@ dws drive download --file-id <dentryUuid> --format json
|
||||
|
||||
# Step 5: 用 Markdown 图片语法发送
|
||||
dws chat message send --group <openconversation_id> \
|
||||
--text "" --format json
|
||||
--title "截图" --text "" --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
Reference in New Issue
Block a user