diff --git a/CHANGELOG.md b/CHANGELOG.md index 933004ba..006c74db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,260 @@ 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.12] - 2026-04-21 + +Product-surface expansion: first-class `doc` (DingTalk Docs) and `minutes` (AI Minutes) skill references, refreshed `aitable` guide aligned with the shipped binary (including dashboard / chart / export), and a README sync that brings the full command catalog to **141 commands across 14 products**. + +### Added + +- **`doc` skill reference** (`skills/references/products/doc.md`) — 16-command coverage of DingTalk Docs: + - Discovery: `search`, `list`, `info`, `read` + - Authoring: `create`, `update`, `folder create` + - Files: `upload`, `download` + - Block-level editing: block `query`, `insert`, `update`, `delete` + - Comments: `comment list`, `create`, `reply` + - URL → `doc_id` extraction rules and nodeId dual-format notes +- **`minutes` skill reference** (`skills/references/products/minutes.md`) — coverage of AI Minutes: + - Lists: personal / shared-with-me / all-accessible + - Content: basic info, AI summary, keywords, transcription, extracted todos, batch detail + - Editing: title update + - Recording control: start, pause, resume, stop +- **SKILL.md routing**: + - Product overview table rows for `doc` and `minutes` + - Intent decision tree routes — `钉钉文档/云文档/知识库/块级编辑/文档评论` → `doc`; `听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词` → `minutes` + - Danger-op table entries: `doc delete`, `doc block delete` + - `aitable` description completed with the `附件` (attachment) group +- **`aitable` skill enhancements**: + - `field create` single-field mode (`--name` / `--type` / `--config`) with examples + - `base get` URL → `baseId` quick-tip + - Dedicated "URL → baseId 提取" chapter + - "`--filters` 筛选语法排错与使用规范" chapter + - "相关产品" cross-link section pointing to `doc` + - **"复杂操作" chapter** (#141) — dashboard / chart workflow (with two-call sequencing and `chart share get` vs `dashboard share get` error semantics) and two-stage `export data` polling (`scope=all/table/view` parameter constraints) +- **README Key Services sync** (#140): + - New rows: `doc` (16 commands), `minutes` (22 commands — adds `hot-word`, `mind-graph`, `replace-text`, `speaker`, `upload` subgroups) + - `aitable` expanded from 20 → 37 commands; surfaces `chart`, `dashboard`, `export`, `import`, `view` subgroups + - Total command count updated from **86 → 141 across 14 products** + - "Coming soon" list drops `doc` and `minutes` + +### Changed + +- `aitable record query` docs rename `--keyword` → `--query` to match the shipped binary +- `aitable record query` docs clarify `--sort` direction semantics (avoids misuse of `order`) +- `aitable base list` guidance strengthened — "only for recent browsing; use `base search` for lookups"; intent decision prioritizes `base search` for base discovery + +## [1.0.11] - 2026-04-20 + +Plugin subsystem hardening: faster cold startup, cleaner lifecycle, stricter isolation, and polished UX for PAT / i18n / error routing. + +### Added + +- `feat: supports claw-like products` — overlay path for Claw-style embedded editions +- `feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses` +- `feat(auth): improve login UX for terminal auth denial cases` — clearer messaging + retry affordance +- `feat: PAT scope error visualization and auto-retry with authorization polling` (#113) + - Human-readable error output (lark-cli style) with type/message/hint/authorization command + - JSON payload also available via `--format json` + - Auto-retry once the user completes scope authorization + +### Changed + +- `perf(plugin): serve plugin MCP tool list from disk cache on startup` — hot path skips Initialize+ListTools when snapshot exists +- `perf(plugin): parallelize all plugin discovery and tighten cold timeouts` — HTTP cold budget 4s → 700ms (auth) / 500ms (plain); stdio and HTTP fan out concurrently +- `perf(plugin): share cache.Store across discovery` — single `*cache.Store` above the fan-out instead of per-goroutine instances +- `refactor(plugin): remove default/managed plugin privileged mechanism` (#124) — third-party plugins install on an equal footing via `dws plugin install` +- `refactor(plugin): purge removed plugin settings instead of merely disabling` — `RemovePlugin` now deletes `EnabledPlugins` and `PluginConfigs` entries + +### Fixed + +- `fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable` (#119) — eliminates the 10s `dws --help` stall caused by compounding transport timeouts +- `fix(plugin): stop stdio child processes on exit and before removal` — no more orphaned plugin subprocesses +- `fix(pat): avoid shared PAT command state in root registration` (#129) +- `fix: -f json 模式下错误 JSON 从 stdout 改为输出到 stderr` (#133) — restores CI stderr-based failure assertions +- `fix(cli): localize plugin/help command strings via i18n` (#118, #134) — zh locale now shows consistent Chinese `--help`; wraps plugin module, help command, and OAuth client-id/secret flag descriptions +- `chore: remove workspace and bundled artifacts` (#127) — clean local-only repository leftovers + +## [1.0.9] - 2026-04-16 + +Plugin system launch + execution-pipeline overhaul. This is the largest release since 1.0.0: third-party MCP servers become first-class commands, the command pipeline grows to five stages, and the edition overlay gains the hooks needed for embedded hosts. + +### Added + +#### Plugin system (new) + +- `plugin` command family: `install`, `list`, `info`, `enable`, `disable`, `remove`, `create`, `dev`, `config set/get/list/unset` +- Plugin manifest parsing/validation, managed/user directory-based identity +- MCP server conversion and injection into the dynamic routing registry +- Pipeline hook adapter for shell-based hooks +- Stdio transport: subprocess lifecycle, `DWS_PLUGIN_ROOT` / `DWS_PLUGIN_DATA` variable expansion +- Stdio server tools automatically registered as CLI subcommands (e.g. `dws hello greet --name Peter`) +- Streamable-HTTP MCP tool discovery via `registerHTTPServer` +- Updater: managed plugin update check on CLI startup (10 s timeout, best-effort) +- `dws plugin create` scaffold (plugin.json, SKILL.md, hooks.json); `dws plugin dev` source-dir registration without copy +- `SyncSkills` — copies plugin skills to agent directories on startup +- **Auth Token Registry**: per-server HTTP headers declared in `plugin.json` for third-party MCP servers (e.g. Alibaba Cloud Bailian) independent from DingTalk OAuth +- **Persistent plugin config** (`dws plugin config ...`): values persisted to `~/.dws/settings.json`, auto-injected as env vars; `${KEY}` in `plugin.json` resolves without manual `export` +- **Build lifecycle**: `build` field compiles stdio servers to native binaries at install time +- **Command-name conflict protection**: reserved built-in names (`auth`, `plugin`, `cache`, …) and plugin-vs-plugin duplicate detection +- Parallel service discovery (`sync.WaitGroup`) — startup reduced from sequential `N*10s` to parallel `max(10s)` + +#### Core commands & diagnostics + +- `dws doctor` — one-stop environment/auth/network diagnostics +- `dws config list` — centralized view of scattered configuration +- Structured perf tracing (upgraded from debug tool to diagnostics output) +- `feat(skill): restore find/get for legacy skill market API` — `skill find`, `skill get`; `skill add` still uses aihub download + +#### Edition / overlay hooks + +- `edition.Hooks.SaveToken` / `LoadToken` / `DeleteToken` — delegate token persistence with keychain fallback +- `edition.Hooks.AuthClientID` / `AuthClientFromMCP` — overlay can override the OAuth client ID and route auth through MCP endpoints +- `edition.Hooks.AfterPersistentPreRun` — wire non-MCP clients (e.g. A2A gateway) after root setup +- `edition.Hooks.ClassifyToolResult` — custom MCP result classification before the default business-error detection +- Token marker file (`token.json`) for embedded hosts to detect auth state without keychain access +- `pkg/runtimetoken.ResolveAccessToken` mirroring MCP auth resolution; MCP identity headers exported via `pkg/cli` for auxiliary HTTP transports +- `ExitCoder` interface — edition-specific errors carry custom exit codes +- `RawStderrError` interface — errors that bypass CLI formatting and emit raw stderr (for desktop runtimes) + +### Changed + +- **Command execution pipeline: 3 → 5 stages** (`Register → PreParse → PostParse → PreRequest → PostResponse`) +- `feat(schema): return structured degraded errors instead of silent empty catalog` — new `CatalogDegraded` error with reasons `unauthenticated` / `market_unreachable` / `runtime_all_failed`; auth pre-check short-circuits doomed MCP connections +- `refactor(auth): unify auxiliary token resolution with MCP cached path` — shared `resolveAccessTokenFromDir`; overlays reuse the process-level token cache +- `feat(plugin): improve CLI overlay resolution and plugin install robustness` + - `plugin.json` `cli` field now accepts a file path (e.g. `"cli": "overlay.json"`) in addition to inline JSON + - `description` field on `CLIToolOverride` for static fallback when MCP `tools/list` is unavailable + - Windows install uses `cmd /C` instead of `sh -c` for build commands + +### Fixed + +- `fix(plugin): harden plugin system security boundaries` + - Reject `file://` / local paths in git URLs; allow only `https` / `ssh` + - Reject symlink entries during ZIP extraction (path-traversal defense) + - `build.output` must be a relative path within the plugin directory + - Reject absolute paths in stdio command declarations + - Block dangerous env var names (`PATH`, `LD_PRELOAD`, …) from plugin config injection +- `fix(plugin): schema flag params, HTTP tool discovery, and integration tests` +- `fix(plugin): skip min version check in dev mode` + +## [1.0.8] - 2026-04-07 + +AITable command surface expansion, installer alignment with npm conventions, and execution-timeout hardening. + +### Added + +- **AITable static helper commands** (20 commands in total) replacing dynamic routing: + - `base`: `list`, `search`, `get`, `create`, `update` + - `table`: `get`, `create`, `update` + - `field`: `get`, `create`, `update` + - `record`: `query`, `create`, `update` + - `template`: `search` + - `attachment`: `upload` +- `feat(install): align skill dirs with npm and add OpenClaw` — skill install paths follow npm conventions; OpenClaw added to supported agents +- Label rendering optimization for AITable records (`to #73551688`) +- README: npm install method documented +- README: note that `dws upgrade` requires v1.0.7+ + +### Changed + +- `perf: optimize command timeout handling, instrumentation, and diagnostics` + +## [1.0.7] - 2026-04-02 + +Self-upgrade, edition overlay foundation, and fail-closed auth enforcement. + +### Added + +- **`dws upgrade`** — self-upgrade via GitHub Releases; atomic replace; cross-platform (macOS/Linux/Windows) +- `feat: edition layer for Wukong overlay` — build-time edition hook lets downstream overlays customize auth UX, config dir, static server list, visible products, and extra root commands + - `pkg/edition` defaults + `pkg/editiontest` contract tests + - `Makefile` target `edition-test`; CI job `edition-tests` + - Static server injection skips market discovery when configured + - Deduplicates top-level commands so overlay wins + - `hideNonDirectRuntimeCommands` respects edition `VisibleProducts` + - Gated `auth login` subcommand + hints for embedded editions + - Optional token auto-purge; edition `ConfigDir` override +- `dws version` — human-readable multi-line output plus JSON with edition, architecture, build, commit +- Tag reporting for case suites (`to #73551688`) +- `feat(auth): unify MCP retry constant and add retry to remaining endpoints` + +### Changed + +- `style(auth): redesign OAuth authorization pages UI` + +### Fixed + +- `fix(auth): switch CLI auth check from fail-open to fail-closed` + - When `/cli/cliAuthEnabled` is unreachable (network error/timeout/5xx), OAuth callback now routes to the permission request page instead of silently marking "enabled" + - Device Flow blocks login and asks the user to verify network connectivity + - `CheckCLIAuthEnabled` retries with backoff (3 attempts, 0s/1s/2s) to tolerate transient issues + +## [1.0.6] - 2026-04-01 + +Error diagnostics overhaul, destructive-command confirmation, and credential auto-persistence. + +### Added + +- **Interactive confirmation for destructive dynamic commands** — prompts before delete/remove operations unless `--yes` is set +- **Enhanced error diagnostics** + - `ServerDiagnostics` struct extracts `trace_id`, `server_error_code`, `technical_detail`, `server_retryable` from MCP responses + - Pulls diagnostics from JSON-RPC `error.data`, tool call result content, and HTTP headers (`X-Trace-Id`, `X-Request-Id`, `x-dingtalk-trace-id`) + - Three verbosity levels for `PrintHuman`: Normal (trace ID + server code), Verbose (+ technical detail), Debug (+ RPC code / operation / reason) + - Local logging now includes sanitized request body, response body on error, retry attempts, and classification events + - `TruncateBody` / `SanitizeArguments` / `RedactHeaders` helpers with sensitive-key substring detection +- **Auth credential persistence** + - `feat(auth): enhance device flow with CLI auth check and admin guidance` + - `feat(auth): persist OAuth credentials for reliable token refresh` + - `feat(auth): persist client credentials and optimize keychain access` — auto-persist `--client-id` / `--client-secret`; keychain credential cache to avoid repeated reads; enhanced logout cleans `app.json` + keychain secrets + `token.json` +- `add report helper with flexible date parsing and defaults` +- `feat: to #73551688 支持消息通知` +- README: Official App mode (recommended, direct login without creating an app) + Custom App mode; admin guide for enabling CLI access + +### Changed + +- Getting Started simplified with inline login commands; whitelist references removed from the IMPORTANT banner +- Version bump documentation updated to v1.0.5 internal; co-creation group QR code refreshed + +### Fixed + +- `fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging` + - `resolveVerbosity` uses `cmd.Flags()` instead of `PersistentFlags()` so subcommands inherit `--verbose` / `--debug` + - `FileLogger` lazy-binds in `executeInvocation` (after `configureLogLevel` init) + - Business errors (HTTP 200 + `success=false`) now written to the file logger for offline diagnosis +- OAuth callback race condition (write response before sending code) +- `import path for errors package in skill_command.go` + +## [1.0.4] - 2026-03-30 + +Token-refresh reliability and onboarding clarity. + +### Added + +- `feat(auth): persist client credentials for token refresh` — `--client-id` / `--client-secret` are stored for automatic refresh after expiration; client secret lives in the system Keychain with a file reference +- README onboarding flow rewrite with step-by-step first-time setup and more realistic examples +- Agent skill reference polish: clearer examples, updated intent routing patterns, expanded `simple.md` onboarding, cross-skill reference fixes + +## [1.0.3] - 2026-03-29 + +Filtering power, schema rendering, and a native `todo` command family. + +### Added + +- **Nested / array-indexed output filtering** + - `--fields` now accepts dot-notation (e.g. `--fields response.content`) and array index access (e.g. `response.items[0]`) + - New field-path parser with recursive extraction logic +- **`schema` command enhancements** + - Table format output for human consumption + - Product-level endpoint loading in the CLI loader + - Schema-text rendering wired into the runner output pipeline +- **`todo` task helper family** — static `create` / `update` / `done` / `get` / `delete` with `preferLegacyLeaf` replacing dynamic commands + - MCP tool alignment: `create_personal_todo`, `update_todo_task`, `update_todo_done_status`, `query_todo_detail`, `delete_todo` + - ISO-8601 due-time parsing + - Hidden title aliases and delete confirmation + - Priority field on `todo` helper + - Expanded zh / en i18n coverage (fixes `en.json` spacing/wording issues) +- README restructured with collapsible feature sections + ## [1.0.2] - 2026-03-29 Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing. diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 05ac73e6..625f723a 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -284,27 +284,6 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati return runner.Run(ctx, invocation) } -// loadMCPClientIDIfNeeded ensures we have a client ID for device flow. -// Priority: in-memory runtime value → DWS_CLIENT_ID env → MCP remote fetch. -func loadMCPClientIDIfNeeded(ctx context.Context, configDir string) string { - clientID := authpkg.ClientID() - if clientID != "" { - return clientID - } - // Fallback: read from environment variable (set by previous PAT auth or caller). - if envID := os.Getenv("DWS_CLIENT_ID"); envID != "" { - authpkg.SetClientIDFromMCP(envID) - return envID - } - // Last resort: fetch from MCP server. - mcpClientID, err := authpkg.FetchClientIDFromMCP(ctx) - if err == nil && mcpClientID != "" { - authpkg.SetClientIDFromMCP(mcpClientID) - return mcpClientID - } - return "" -} - // ---- handlePatAuthCheck (runner.go entry point) ----------------------------- const ( diff --git a/internal/app/root.go b/internal/app/root.go index 2a036ad2..d7cf3b8a 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -1091,18 +1091,13 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma } } - // 1. Load plugins from the legacy managed/ directory (backward compat - // for plugins installed by older CLI builds). - managedPlugins := pluginLoader.LoadManaged() - - // 2. Load user plugins (per settings.json) + // 1. Load user plugins (per settings.json) userPlugins := pluginLoader.LoadUser() - // 3. Load dev plugins (registered via `dws plugin dev`) + // 2. Load dev plugins (registered via `dws plugin dev`) devPlugins := pluginLoader.LoadDev() - allPlugins := append(managedPlugins, userPlugins...) - allPlugins = append(allPlugins, devPlugins...) + allPlugins := append(userPlugins, devPlugins...) // 3. Discover tools from streamable-http servers and build CLI commands. // Third-party servers with auth headers are discovered in parallel @@ -1213,7 +1208,6 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma if len(allPlugins) > 0 { slog.Debug("plugins loaded", - "managed", len(managedPlugins), "user", len(userPlugins), "dev", len(devPlugins), ) @@ -1529,17 +1523,12 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t // Construct virtual endpoint and server descriptor. endpoint := StdioEndpoint(p.Manifest.Name, sc.Key) - source := "plugin" - if p.IsManaged { - source = "plugin-managed" - } - descriptor := market.ServerDescriptor{ Key: sc.Key, DisplayName: p.Manifest.Name + "/" + sc.Key, Description: p.Manifest.Description, Endpoint: endpoint, - Source: source, + Source: "plugin", CLI: overlay, HasCLIMeta: true, } diff --git a/internal/auth/classify_denial_reason_test.go b/internal/auth/classify_denial_reason_test.go new file mode 100644 index 00000000..3fde4344 --- /dev/null +++ b/internal/auth/classify_denial_reason_test.go @@ -0,0 +1,144 @@ +// 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 "testing" + +func TestClassifyDenialReason(t *testing.T) { + cases := []struct { + name string + status *CLIAuthStatus + currentChannel string + want string + }{ + { + name: "error CHANNEL_REQUIRED", + status: &CLIAuthStatus{ + ErrorCode: "CHANNEL_REQUIRED", + }, + want: "channel_required", + }, + { + name: "error NO_AUTH", + status: &CLIAuthStatus{ + ErrorCode: "NO_AUTH", + }, + want: "no_auth", + }, + { + name: "success false or nil result → unknown", + status: &CLIAuthStatus{ + Success: false, + }, + want: "unknown", + }, + { + name: "cliAuthEnabled true → no denial", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{CLIAuthEnabled: true}, + }, + want: "", + }, + { + name: "userScope forbidden wins over channel", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{ + CLIAuthEnabled: false, + UserScope: "forbidden", + ChannelScope: "specified", + AllowedChannels: []string{"channel-a"}, + }, + }, + currentChannel: "channel-b", + want: "user_forbidden", + }, + { + // Real-world case reported: user is in allowedUsers but the current + // DWS_CHANNEL is not in allowedChannels. Reason must be channel, + // NOT user. + name: "user allowed but channel not in allowedChannels → channel_not_allowed", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{ + CLIAuthEnabled: false, + UserScope: "specified", + AllowedUsers: []string{"014566033934857460"}, + ChannelScope: "specified", + AllowedChannels: []string{"2a4a658e467998befb7fa333c19ba2b3a3bacfa4"}, + }, + }, + currentChannel: "different-channel", + want: "channel_not_allowed", + }, + { + name: "channelScope specified but current channel empty → channel_required", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{ + CLIAuthEnabled: false, + UserScope: "specified", + ChannelScope: "specified", + AllowedChannels: []string{"channel-a"}, + }, + }, + currentChannel: "", + want: "channel_required", + }, + { + name: "channel matches allowedChannels → fall back to user denial", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{ + CLIAuthEnabled: false, + UserScope: "specified", + ChannelScope: "specified", + AllowedChannels: []string{"channel-a"}, + }, + }, + currentChannel: "channel-a", + want: "user_not_allowed", + }, + { + name: "only userScope=specified, no channel restriction → user_not_allowed", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{ + CLIAuthEnabled: false, + UserScope: "specified", + }, + }, + currentChannel: "", + want: "user_not_allowed", + }, + { + name: "no user or channel restriction → cli_not_enabled", + status: &CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{CLIAuthEnabled: false}, + }, + want: "cli_not_enabled", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classifyDenialReason(tc.status, tc.currentChannel) + if got != tc.want { + t.Fatalf("classifyDenialReason() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index c21cf5a7..6c8f9147 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -185,11 +185,6 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token if err != nil { return nil, err } - if tokenResult == nil { - // FlowID was empty — no polling happened; authorization URL was already - // printed, so the user can handle it manually. - return nil, nil - } _, _ = fmt.Fprintln(p.output(), "") dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0) @@ -377,15 +372,15 @@ func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string } func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) { - // No FlowID from server — cannot poll status; return immediately so the - // user can still see the authorization URL printed earlier and handle it - // manually (same pattern as pat_auth_retry.go L451). if auth.FlowID == "" { - dfPrintDim(p.output(), i18n.T(" 服务端未返回 flowId,跳过轮询,请在浏览器中手动完成授权后重试")) - _, _ = fmt.Fprintln(p.output(), "") - return nil, nil + // Keep the pre-flowId device-code polling path for regular device flow + // login responses that do not include terminal polling metadata. + return p.waitForAuthorizationByDeviceCode(ctx, auth) } + return p.waitForAuthorizationByFlowID(ctx, auth) +} +func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) { startTime := time.Now() interval := time.Duration(auth.Interval) * time.Second deadline := time.Duration(auth.ExpiresIn) * time.Second @@ -435,6 +430,63 @@ func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *Dev } } +func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) { + startTime := time.Now() + interval := time.Duration(auth.Interval) * time.Second + deadline := time.Duration(auth.ExpiresIn) * time.Second + pollCount := 0 + + for { + elapsed := time.Since(startTime) + if elapsed >= maxPollTotalWait || elapsed >= deadline { + _, _ = fmt.Fprintln(p.output(), "") + return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn)) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + + pollCount++ + elapsedSec := int(time.Since(startTime).Seconds()) + dfPrintPollStatus(p.output(), pollCount, elapsedSec) + + resp, err := p.pollDeviceToken(ctx, auth.DeviceCode) + if err != nil { + dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试...")) + if p.logger != nil { + p.logger.Debug("poll error", "error", err) + } + continue + } + + if resp.Error == "" { + dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!")) + return resp, nil + } + switch resp.Error { + case "authorization_pending": + dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权...")) + case "slow_down": + interval += 5 * time.Second + if interval > maxPollInterval*time.Second { + interval = maxPollInterval * time.Second + } + dfPrintPollResult(p.output(), "slow_down", fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), int(interval.Seconds()))) + case "access_denied": + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("用户拒绝了授权请求")) + case "expired_token": + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("设备授权码已过期")) + default: + dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知错误: %s"), resp.Error)) + } + } +} + func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, params url.Values) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(params.Encode())) if err != nil { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 2a3c38d8..4e035e9b 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -14,8 +14,11 @@ package auth import ( + "bytes" "context" "encoding/json" + "errors" + "fmt" "io" "log/slog" "net/http" @@ -24,6 +27,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" ) func newDeviceFlowTestLogger() *slog.Logger { @@ -138,6 +143,55 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) { } } +func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) + } + if got := r.FormValue("device_code"); got != "legacy-device-code" { + t.Fatalf("device_code = %q, want legacy-device-code", got) + } + if calls.Add(1) <= 2 { + writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "") + return + } + writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "legacy-auth-code"}, "", "") + })) + defer server.Close() + + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + var output bytes.Buffer + provider.Output = &output + provider.SetBaseURL(server.URL) + + resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{ + DeviceCode: "legacy-device-code", + ExpiresIn: 10, + Interval: 1, + }) + if err != nil { + t.Fatalf("waitForAuthorization() error = %v", err) + } + if resp.AuthCode != "legacy-auth-code" { + t.Fatalf("auth code = %q, want legacy-auth-code", resp.AuthCode) + } + if calls.Load() != 3 { + t.Fatalf("poll calls = %d, want 3", calls.Load()) + } + if !strings.Contains(output.String(), i18n.T("等待用户授权...")) { + t.Fatalf("expected device-code path to emit pending output, got %q", output.String()) + } + if !strings.Contains(output.String(), i18n.T("授权成功!")) { + t.Fatalf("expected device-code path to emit success output, got %q", output.String()) + } +} + func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) { t.Parallel() @@ -166,3 +220,105 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) { t.Fatal("waitForAuthorization() error = nil, want context cancellation") } } + +func TestWaitForAuthorizationByDeviceCodeHonorsContextCancellation(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "") + })) + defer server.Close() + + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + provider.Output = io.Discard + provider.SetBaseURL(server.URL) + + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + + _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{ + DeviceCode: "legacy-device-code", + ExpiresIn: 60, + Interval: 1, + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waitForAuthorization() error = %v, want context deadline exceeded", err) + } +} + +func TestWaitForAuthorizationByDeviceCodeErrorStates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeout time.Duration + responses []DeviceTokenResponse + wantErr string + wantErrIs error + wantOutput string + }{ + { + name: "slow_down_then_context_cancelled", + timeout: 1500 * time.Millisecond, + responses: []DeviceTokenResponse{{Error: "slow_down"}}, + wantErrIs: context.DeadlineExceeded, + wantOutput: fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), 6), + }, + { + name: "access_denied", + timeout: 5 * time.Second, + responses: []DeviceTokenResponse{{Error: "access_denied"}}, + wantErr: i18n.T("用户拒绝了授权请求"), + wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1), + }, + { + name: "expired_token", + timeout: 5 * time.Second, + responses: []DeviceTokenResponse{{Error: "expired_token"}}, + wantErr: i18n.T("设备授权码已过期"), + wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idx := int(calls.Add(1)) - 1 + if idx >= len(tt.responses) { + idx = len(tt.responses) - 1 + } + writeServiceResult(w, true, tt.responses[idx], "", "") + })) + defer server.Close() + + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + var output bytes.Buffer + provider.Output = &output + provider.SetBaseURL(server.URL) + + ctx, cancel := context.WithTimeout(context.Background(), tt.timeout) + defer cancel() + + _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{ + DeviceCode: "legacy-device-code", + ExpiresIn: 60, + Interval: 1, + }) + + if tt.wantErrIs != nil { + if !errors.Is(err, tt.wantErrIs) { + t.Fatalf("waitForAuthorization() error = %v, want %v", err, tt.wantErrIs) + } + } else if err == nil || err.Error() != tt.wantErr { + t.Fatalf("waitForAuthorization() error = %v, want %q", err, tt.wantErr) + } + + if !strings.Contains(output.String(), tt.wantOutput) { + t.Fatalf("expected output to contain %q, got %q", tt.wantOutput, output.String()) + } + }) + } +} diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index d6df1075..372b353a 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "os" + "slices" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" @@ -1129,15 +1130,26 @@ type CLIAuthStatus struct { // The server computes cliAuthEnabled by considering the org switch, userScope, // and channelScope together; the CLI uses it as-is. type CLIAuthResult struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden" - AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified" - ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified" - AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified" + CLIAuthEnabled bool `json:"cliAuthEnabled"` + UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden" + AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified" + ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified" + AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified" + ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured } // classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable // denial reason string. Returns "" when access is granted. +// +// Priority rationale: +// 1. Explicit org-wide ban (userScope=forbidden) always wins. +// 2. Channel scope is evaluated BEFORE user scope because the CLI has +// authoritative knowledge of DWS_CHANNEL and can verify membership against +// allowedChannels. This avoids falsely blaming the user when the real +// denial cause is a channel mismatch (e.g. user is in allowedUsers but the +// current channel is not in allowedChannels). +// 3. Only when the channel is unrestricted or matches do we attribute the +// denial to the user scope. func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { if status.ErrorCode == "CHANNEL_REQUIRED" { return "channel_required" @@ -1148,18 +1160,26 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { if status.Result == nil || !status.Success { return "unknown" } - if status.Result.CLIAuthEnabled { + r := status.Result + if r.CLIAuthEnabled { return "" } - // cliAuthEnabled=false — infer reason from auxiliary fields (same priority as server) - if status.Result.UserScope == "forbidden" { + + if r.UserScope == "forbidden" { return "user_forbidden" } - if status.Result.UserScope == "specified" { - return "user_not_allowed" + + if r.ChannelScope == "specified" { + if currentChannel == "" { + return "channel_required" + } + if !slices.Contains(r.AllowedChannels, currentChannel) { + return "channel_not_allowed" + } } - if status.Result.ChannelScope == "specified" && currentChannel != "" { - return "channel_not_allowed" + + if r.UserScope == "specified" { + return "user_not_allowed" } return "cli_not_enabled" } diff --git a/internal/plugin/converter.go b/internal/plugin/converter.go index 4fdf630c..ee74a57a 100644 --- a/internal/plugin/converter.go +++ b/internal/plugin/converter.go @@ -131,9 +131,6 @@ func (p *Plugin) ToServerDescriptors() []market.ServerDescriptor { } source := "plugin" - if p.IsManaged { - source = "plugin-managed" - } // Resolve headers: expand environment variable references (e.g. ${DASHSCOPE_API_KEY}). var resolvedHeaders map[string]string diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index 342131c7..3910376c 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -56,13 +56,6 @@ type Settings struct { DevPlugins map[string]string `json:"devPlugins,omitempty"` // name → absolute path } -// LoadManaged scans ~/.dws/plugins/managed/ and returns all valid -// official plugins. Managed plugins are always enabled. -func (l *Loader) LoadManaged() []*Plugin { - managedDir := filepath.Join(l.PluginsDir, "managed") - return l.scanDir(managedDir, true) -} - // LoadUser scans ~/.dws/plugins/user/ and returns enabled user plugins. func (l *Loader) LoadUser() []*Plugin { userDir := filepath.Join(l.PluginsDir, "user") @@ -86,7 +79,7 @@ func (l *Loader) LoadUser() []*Plugin { // Check if this is a direct plugin directory (has plugin.json) if _, err := os.Stat(filepath.Join(entryPath, "plugin.json")); err == nil { - p := l.loadPlugin(entryPath, false) + p := l.loadPlugin(entryPath) if p != nil && isPluginEnabled(settings, p.Manifest.Name) { plugins = append(plugins, p) } @@ -103,7 +96,7 @@ func (l *Loader) LoadUser() []*Plugin { continue } subPath := filepath.Join(entryPath, sub.Name()) - p := l.loadPlugin(subPath, false) + p := l.loadPlugin(subPath) if p != nil { qualifiedName := entry.Name() + "/" + p.Manifest.Name if isPluginEnabled(settings, qualifiedName) { @@ -115,39 +108,15 @@ func (l *Loader) LoadUser() []*Plugin { return plugins } -// LoadAll loads both managed and user plugins. +// LoadAll loads user + dev plugins. func (l *Loader) LoadAll() []*Plugin { - managed := l.LoadManaged() user := l.LoadUser() - return append(managed, user...) -} - -// scanDir reads a directory of plugin subdirectories and loads each one. -func (l *Loader) scanDir(dir string, isManaged bool) []*Plugin { - entries, err := os.ReadDir(dir) - if err != nil { - if !os.IsNotExist(err) { - slog.Debug("plugin: cannot read dir", "path", dir, "error", err) - } - return nil - } - - var plugins []*Plugin - for _, entry := range entries { - if !entry.IsDir() { - continue - } - pluginDir := filepath.Join(dir, entry.Name()) - p := l.loadPlugin(pluginDir, isManaged) - if p != nil { - plugins = append(plugins, p) - } - } - return plugins + dev := l.LoadDev() + return append(user, dev...) } // loadPlugin reads and validates a single plugin directory. -func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin { +func (l *Loader) loadPlugin(dir string) *Plugin { manifestPath := filepath.Join(dir, "plugin.json") manifest, err := ParseManifest(manifestPath) if err != nil { @@ -163,9 +132,8 @@ func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin { } return &Plugin{ - Manifest: *manifest, - Root: dir, - IsManaged: isManaged, + Manifest: *manifest, + Root: dir, } } @@ -211,7 +179,7 @@ func isPluginEnabled(s *Settings, name string) bool { type PluginInfo struct { Name string `json:"name"` Version string `json:"version"` - Type string `json:"type"` // "managed" or "user" + Type string `json:"type"` // "user" or "dev" Enabled bool `json:"enabled"` Path string `json:"path"` Description string `json:"description,omitempty"` @@ -222,29 +190,6 @@ func (l *Loader) ListInstalled() []PluginInfo { var result []PluginInfo settings := l.loadSettings() - // Managed plugins - managedDir := filepath.Join(l.PluginsDir, "managed") - if entries, err := os.ReadDir(managedDir); err == nil { - for _, entry := range entries { - if !entry.IsDir() { - continue - } - dir := filepath.Join(managedDir, entry.Name()) - m, err := ParseManifest(filepath.Join(dir, "plugin.json")) - if err != nil { - continue - } - result = append(result, PluginInfo{ - Name: m.Name, - Version: m.Version, - Type: "managed", - Enabled: true, // managed plugins always enabled - Path: dir, - Description: m.Description, - }) - } - } - // User plugins userDir := filepath.Join(l.PluginsDir, "user") if entries, err := os.ReadDir(userDir); err == nil { @@ -348,9 +293,8 @@ func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) { l.setPluginEnabled(manifest.Name, true) return &Plugin{ - Manifest: *manifest, - Root: destDir, - IsManaged: false, + Manifest: *manifest, + Root: destDir, }, nil } @@ -411,9 +355,8 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) { l.setPluginEnabled(qualifiedName, true) return &Plugin{ - Manifest: *manifest, - Root: destDir, - IsManaged: false, + Manifest: *manifest, + Root: destDir, }, nil } @@ -463,19 +406,9 @@ func parseGitURL(gitURL string) (workspace, repoName string, err error) { return segments[len(segments)-2], segments[len(segments)-1], nil } -// RemovePlugin removes an installed plugin by name. It searches both the -// user and the legacy managed directories; all plugins are equally -// removable. +// RemovePlugin removes an installed plugin by name. func (l *Loader) RemovePlugin(name string, keepData bool) error { pluginDir := l.findUserPluginDir(name) - if pluginDir == "" { - // Fall back to legacy managed/ directory (for plugins installed - // by older CLI builds that wrote under ~/.dws/plugins/managed/). - legacyDir := filepath.Join(l.PluginsDir, config.PluginManagedDir, name) - if _, err := os.Stat(filepath.Join(legacyDir, "plugin.json")); err == nil { - pluginDir = legacyDir - } - } if pluginDir == "" { return fmt.Errorf("plugin %q not found", name) } @@ -516,12 +449,8 @@ func (l *Loader) purgePluginFromSettings(name string) { // SetEnabled enables or disables a plugin in settings.json. func (l *Loader) SetEnabled(name string, enabled bool) error { - // Verify plugin exists if l.findUserPluginDir(name) == "" { - managedDir := filepath.Join(l.PluginsDir, "managed", name) - if _, err := os.Stat(managedDir); err != nil { - return fmt.Errorf("plugin %q not found", name) - } + return fmt.Errorf("plugin %q not found", name) } l.setPluginEnabled(name, enabled) return nil @@ -695,7 +624,7 @@ func (l *Loader) LoadDev() []*Plugin { "name", name, "dir", dir) continue } - p := l.loadPlugin(dir, false) + p := l.loadPlugin(dir) if p != nil { plugins = append(plugins, p) slog.Debug("plugin: loaded dev plugin", "name", name, "dir", dir) diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index 923e5490..3c4b9de4 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -93,9 +93,8 @@ type HookEntry struct { // Plugin is a loaded, validated plugin ready for injection. type Plugin struct { - Manifest Manifest - Root string // absolute path to plugin directory - IsManaged bool // true for official (DingTalk-Real-AI) plugins + Manifest Manifest + Root string // absolute path to plugin directory } // ParseManifest reads and parses a plugin.json file. diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 2415cff3..dca88fd7 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -199,8 +199,7 @@ func TestPluginToServerDescriptors(t *testing.T) { }, }, }, - Root: "/tmp/plugins/conference", - IsManaged: true, + Root: "/tmp/plugins/conference", } descriptors := p.ToServerDescriptors() @@ -217,8 +216,8 @@ func TestPluginToServerDescriptors(t *testing.T) { if d.Endpoint != "https://mcp.conference.dingtalk.com" { t.Errorf("endpoint = %q", d.Endpoint) } - if d.Source != "plugin-managed" { - t.Errorf("source = %q, want plugin-managed", d.Source) + if d.Source != "plugin" { + t.Errorf("source = %q, want plugin", d.Source) } if d.CLI.ID != "conference" { t.Errorf("cli.id = %q, want conference", d.CLI.ID) @@ -273,7 +272,7 @@ func TestPluginToServerDescriptorsWithHeaders(t *testing.T) { t.Errorf("AuthHeaders[X-Custom] = %q, want static-value", d.AuthHeaders["X-Custom"]) } if d.Source != "plugin" { - t.Errorf("source = %q, want plugin (non-managed)", d.Source) + t.Errorf("source = %q, want plugin", d.Source) } } @@ -354,137 +353,57 @@ func TestLoaderScanEmpty(t *testing.T) { CLIVersion: "1.0.0", } - managed := loader.LoadManaged() - if len(managed) != 0 { - t.Errorf("expected 0 managed plugins, got %d", len(managed)) - } - user := loader.LoadUser() if len(user) != 0 { t.Errorf("expected 0 user plugins, got %d", len(user)) } } -func TestLoaderLoadManaged(t *testing.T) { - dir := t.TempDir() - managedDir := filepath.Join(dir, "managed", "conference") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - - manifest := `{ - "name": "conference", - "version": "1.0.0", - "type": "managed", - "mcpServers": { - "conference": { - "type": "streamable-http", - "endpoint": "https://example.com" - } - } - }` - if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(manifest), 0o644); err != nil { - t.Fatal(err) - } - - loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"} - plugins := loader.LoadManaged() - - if len(plugins) != 1 { - t.Fatalf("expected 1 managed plugin, got %d", len(plugins)) - } - if plugins[0].Manifest.Name != "conference" { - t.Errorf("name = %q, want conference", plugins[0].Manifest.Name) - } - if !plugins[0].IsManaged { - t.Error("expected IsManaged = true") - } -} - -// TestRemoveLegacyManagedPlugin ensures plugins that were installed under -// the legacy ~/.dws/plugins/managed/ directory are now freely removable -// — the old "cannot be removed" privilege has been dropped. -func TestRemoveLegacyManagedPlugin(t *testing.T) { - dir := t.TempDir() - managedDir := filepath.Join(dir, "managed", "conference") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(`{"name":"conference","version":"1.0.0"}`), 0o644); err != nil { - t.Fatal(err) - } - - loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"} - if err := loader.RemovePlugin("conference", false); err != nil { - t.Fatalf("unexpected error removing legacy managed plugin: %v", err) - } - if _, err := os.Stat(managedDir); !os.IsNotExist(err) { - t.Errorf("managed plugin dir should be removed, stat err = %v", err) - } -} - // TestRemovePluginPurgesSettings verifies RemovePlugin fully purges the // plugin's settings — both its enabled flag and any pluginConfigs entry — // so settings.json does not retain dangling state for a plugin that no -// longer exists on disk. Covers both the user and legacy managed paths. +// longer exists on disk. func TestRemovePluginPurgesSettings(t *testing.T) { - cases := []struct { - name string - layout string // "user" or "legacy" - pkgName string - }{ - {name: "user plugin", layout: "user", pkgName: "my-plugin"}, - {name: "legacy managed plugin", layout: "legacy", pkgName: "conference"}, + const pkgName = "my-plugin" + dir := t.TempDir() + pluginDir := filepath.Join(dir, "user", pkgName) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"`+pkgName+`","version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - var pluginDir string - switch tc.layout { - case "user": - pluginDir = filepath.Join(dir, "user", tc.pkgName) - case "legacy": - pluginDir = filepath.Join(dir, "managed", tc.pkgName) - } - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), - []byte(`{"name":"`+tc.pkgName+`","version":"1.0.0"}`), 0o644); err != nil { - t.Fatal(err) - } - loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"} + loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"} - // Seed settings.json with an explicit enabled flag and a - // pluginConfigs entry to verify both get purged. - settings := &Settings{ - EnabledPlugins: map[string]bool{tc.pkgName: true, "other-plugin": true}, - PluginConfigs: map[string]map[string]any{ - tc.pkgName: {"API_KEY": "secret"}, - "other-plugin": {"TOKEN": "keep-me"}, - }, - } - loader.saveSettings(settings) + // Seed settings.json with an explicit enabled flag and a + // pluginConfigs entry to verify both get purged. + settings := &Settings{ + EnabledPlugins: map[string]bool{pkgName: true, "other-plugin": true}, + PluginConfigs: map[string]map[string]any{ + pkgName: {"API_KEY": "secret"}, + "other-plugin": {"TOKEN": "keep-me"}, + }, + } + loader.saveSettings(settings) - if err := loader.RemovePlugin(tc.pkgName, false); err != nil { - t.Fatalf("RemovePlugin: %v", err) - } + if err := loader.RemovePlugin(pkgName, false); err != nil { + t.Fatalf("RemovePlugin: %v", err) + } - reloaded := loader.loadSettings() - if _, exists := reloaded.EnabledPlugins[tc.pkgName]; exists { - t.Errorf("EnabledPlugins should not retain removed plugin %q", tc.pkgName) - } - if _, exists := reloaded.PluginConfigs[tc.pkgName]; exists { - t.Errorf("PluginConfigs should not retain removed plugin %q", tc.pkgName) - } - if !reloaded.EnabledPlugins["other-plugin"] { - t.Error("unrelated EnabledPlugins entry should be preserved") - } - if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" { - t.Error("unrelated PluginConfigs entry should be preserved") - } - }) + reloaded := loader.loadSettings() + if _, exists := reloaded.EnabledPlugins[pkgName]; exists { + t.Errorf("EnabledPlugins should not retain removed plugin %q", pkgName) + } + if _, exists := reloaded.PluginConfigs[pkgName]; exists { + t.Errorf("PluginConfigs should not retain removed plugin %q", pkgName) + } + if !reloaded.EnabledPlugins["other-plugin"] { + t.Error("unrelated EnabledPlugins entry should be preserved") + } + if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" { + t.Error("unrelated PluginConfigs entry should be preserved") } } diff --git a/pkg/config/constants.go b/pkg/config/constants.go index f3cdc10b..39755cb5 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -112,12 +112,6 @@ const ( // ── Plugin system ────────────────────────────────────────────────────── const ( - // PluginManagedDir is a legacy subdirectory under ~/.dws/plugins/ - // retained so plugins installed by older CLI versions remain loadable. - // New installs always go to PluginUserDir; there is no privilege - // associated with this directory anymore. - PluginManagedDir = "managed" - // PluginUserDir is the subdirectory under ~/.dws/plugins/ where all // third-party plugins are installed. Every plugin — whether authored // by the DingTalk team or anyone else — lives here with equal status. diff --git a/skills/references/products/chat.md b/skills/references/products/chat.md index 91b2c2c5..47ec2465 100644 --- a/skills/references/products/chat.md +++ b/skills/references/products/chat.md @@ -1,77 +1,113 @@ -# 群聊与机器人 (chat) 命令参考 +# 会话与群聊 (chat) 命令参考 + +> 命令别名: `dws im` 等价于 `dws chat` ## 命令总览 +### group (群组管理) + | 子命令 | 用途 | |-------|------| -| `search` | 搜索群聊 | -| `group create` | 创建群 | +| `group create` | 创建内部群 | +| `group create-org` | 创建企业全员群 | | `group members list` | 查看群成员列表 | | `group members add` | 添加群成员 | | `group members remove` | 移除群成员(⚠️ 危险操作) | | `group members add-bot` | 添加机器人到群 | | `group rename` | 修改群名称 | -| `bot search` | 搜索我的机器人 | +| `search` | 搜索群会话 | +| `search-common` | 搜索共同群 | + +### message (会话消息管理) + +| 子命令 | 用途 | +|-------|------| +| `message send` | 以当前用户身份发群消息或单聊消息 | +| `message send-personal` | 发送个人消息(⚠️ 敏感操作) | +| `message list` | 拉取群聊或单聊会话消息 | +| `message list-all` | 按时间范围拉取当前用户所有会话消息 | +| `message list-topic-replies` | 拉取群话题回复消息列表 | +| `message list-by-sender` | 搜索指定发送者的消息 | +| `message list-mentions` | 拉取 @我 的消息 | +| `message list-focused` | 拉取特别关注人的消息 | +| `message list-unread-conversations` | 获取未读会话列表 | +| `message search` | 按关键词搜索消息 | +| `message info` | 获取会话信息 | | `message send-by-bot` | 机器人发消息(群聊或批量单聊) | -| `message recall-by-bot` | 机器人撤回消息(群聊或批量单聊) | +| `message recall-by-bot` | 机器人撤回消息 | | `message send-by-webhook` | 自定义机器人 Webhook 发消息 | +| `list-top-conversations` | 拉取置顶会话列表 | + +### bot (机器人管理) + +| 子命令 | 用途 | +|-------|------| +| `bot search` | 搜索我的机器人 | +| `bot create` | 创建企业机器人 | +| `bot search-groups` | 搜索机器人所在群 | --- -## search — 搜索群聊 +## group create — 创建内部群 -``` -Usage: - dws chat search [flags] -Example: - dws chat search --query "项目冲刺" --format json -Flags: - --query string 搜索关键词 (必填) - --cursor string 分页游标(首页留空) -``` - ---- - -## group create — 创建群 +当前登录用户自动成为群主。 ``` Usage: dws chat group create [flags] Example: - dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 --format json + dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 Flags: - --name string 群名称 (必填) - --users string 群成员 userId 列表,逗号分隔 (必填) + --users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填) + --name string 群名称 (必填) ``` -> 当前用户自动作为群主加入,无需在 --users 中重复传入。 +--- + +## group create-org — 创建企业全员群 + +创建面向企业组织的群,成员通过 userId 列表指定。 + +``` +Usage: + dws chat group create-org [flags] +Example: + dws chat group create-org --name "全员通知群" --users userId1,userId2 +Flags: + --name string 群名称 (必填) + --users string 成员 userId 列表,逗号分隔 (必填) +``` --- ## group members list — 查看群成员列表 +分页查询指定群聊的成员。 + ``` Usage: dws chat group members list [flags] Example: - dws chat group members list --id --format json + dws chat group members list --id Flags: - --id string 群会话 ID (必填) - --cursor string 分页游标 + --cursor string 分页游标,首次从 0 开始 + --id string 群 ID / openconversation_id (必填) ``` --- ## group members add — 添加群成员 +向指定群聊添加成员,需传入群 ID 与用户 ID 列表。 + ``` Usage: dws chat group members add [flags] Example: - dws chat group members add --id --users userId1,userId2 --format json + dws chat group members add --id --users userId1,userId2 Flags: - --id string 群会话 ID (必填) - --users string 要添加的 userId 列表,逗号分隔 (必填) + --id string 群 ID / openconversation_id (必填) + --users string 要添加的用户 userId 列表,逗号分隔 (必填) ``` --- @@ -84,24 +120,26 @@ Flags: Usage: dws chat group members remove [flags] Example: - dws chat group members remove --id --users userId1,userId2 --format json + dws chat group members remove --id --users userId1,userId2 Flags: - --id string 群会话 ID (必填) - --users string 要移除的 userId 列表,逗号分隔 (必填) + --id string 群 ID / openconversation_id (必填) + --users string 要移除的用户 userId 列表,逗号分隔 (必填) ``` --- ## group members add-bot — 添加机器人到群 +将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。 + ``` Usage: dws chat group members add-bot [flags] Example: - dws chat group members add-bot --id --robot-code --format json + dws chat group members add-bot --robot-code --id Flags: - --id string 群会话 ID (必填) - --robot-code string 机器人 code (必填) + --id string 群聊 openConversationId (必填) + --robot-code string 机器人 Code (必填) ``` --- @@ -112,10 +150,351 @@ Flags: Usage: dws chat group rename [flags] Example: - dws chat group rename --id --name "新群名" --format json + dws chat group rename --id --name "新群名" Flags: - --id string 群会话 ID (必填) - --name string 新群名称 (必填) + --id string 群 ID / openconversation_id (必填) + --name string 修改后的群名称 (必填) +``` + +--- + +## search — 搜索群会话 + +根据名称搜索会话列表。 + +``` +Usage: + dws chat search [flags] +Example: + dws chat search --query "项目冲刺" +Flags: + --cursor string 分页游标 (首页留空) + --query string 搜索关键词 (必填) +``` + +--- + +## search-common — 搜索共同群 + +根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。 + +``` +Usage: + dws chat search-common [flags] +Example: + dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0 + dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0 + dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor +Flags: + --nicks string 要搜索的昵称列表,逗号分隔 (必填) + --match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND) + --limit int 每页返回数量(默认 20) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔" + - --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message send — 以当前用户身份发消息 + +--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。 +--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。 +--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。 + +``` +Usage: + dws chat message send [flags] [] +Example: + dws chat message send --group --text "hello" + dws chat message send --user --text "请查收" + dws chat message send --open-dingtalk-id --text "请查收" + dws chat message send --group "hello" + dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" + dws chat message send --group --at-all "<@all> 请大家注意" + dws chat message send --group --at-users userId1,userId2 "<@userId1> <@userId2> 请查收" + dws chat message send --group --media-id + dws chat message send --open-dingtalk-id --media-id +Flags: + --text string 消息内容(推荐使用,也可用位置参数) + --group string 群聊 openconversation_id(群聊时必填) + --user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + --title string 消息标题(可选,默认「消息」) + --at-all @所有人(仅群聊时生效,可选,默认 false) + --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选) + --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容 + +注意: + - --text 和位置参数二选一,--text 优先 + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - --at-all 和 --at-users 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2> + - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user +``` + +--- + +## message send-personal — 发送个人消息 + +> ⚠️ 敏感操作:执行前必须向用户确认,同意后才加 `--yes`。 + +发送个人消息到指定会话或指定用户。支持 @指定人。 + +``` +Usage: + dws chat message send-personal [flags] +Example: + dws chat message send-personal --id --content "你好" --type text + dws chat message send-personal --open-id --content "消息内容" --type text + dws chat message send-personal --id --content "内容" --at-all +Flags: + --content string 消息内容 (必填) + --type string 消息类型,如 text、markdown (必填) + --id string 群聊会话 ID openConversationId(与 --open-id 二选一) + --open-id string 接收人 openDingTalkId(与 --id 二选一) + --at-all @所有人(可选) + --at-users string @指定人的 openDingTalkId 列表,逗号分隔(可选) + +注意: + - --id(群聊会话)和 --open-id(指定用户 openDingTalkId)二选一 +``` + +--- + +## message list — 拉取会话消息内容 + +拉取指定群聊或单聊的会话消息内容。 + +--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。 + +``` +Usage: + dws chat message list [flags] +Example: + dws chat message list --group --time "2025-03-01 00:00:00" + 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" --forward=false +Flags: + --forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true) + --group string 群聊 openconversation_id(群聊时必填) + --limit int 返回数量,不传则不限制 + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + +注意: + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time + - 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数 +``` + +--- + +## message list-all — 拉取指定时间范围内当前用户的所有会话消息 + +分页拉取当前登录用户在指定时间范围内的所有会话消息。 + +--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。 + +``` +Usage: + dws chat message list-all [flags] +Example: + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" +Flags: + --start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取) + +注意: + - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" + - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 + - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 + - 时间格式统一为 yyyy-MM-dd HH:mm:ss +``` + +--- + +## message list-topic-replies — 拉取群话题回复消息列表 + +查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。 + +``` +Usage: + dws chat message list-topic-replies [flags] +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 +Flags: + --group string 群会话 openconversationId (必填) + --topic-id string 话题 ID,由 dws chat message list 返回 (必填) + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选) + --limit int 返回数量(默认 50) + --forward true=从老往新,false=从新往老(默认 false) +``` + +--- + +## message list-by-sender — 拉取指定发送者的消息 + +搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。 + +``` +Usage: + dws chat message list-by-sender [flags] +Example: + dws chat message list-by-sender --sender-user-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-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) + --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一 + - 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识 + - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-mentions — 拉取 @我 的消息 + +搜索时间范围内 @我 的消息,可选指定群聊。 + +``` +Usage: + dws chat message list-mentions [flags] +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 --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --group string 群聊 openconversation_id(可选,不传则查全部) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-focused — 拉取特别关注人的消息 + +拉取当前用户特别关注人的消息。 + +``` +Usage: + dws chat message list-focused [flags] +Example: + dws chat message list-focused --limit 50 + dws chat message list-focused --limit 20 --cursor +Flags: + --limit int 每页返回数量(默认 50) + --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor +``` + +--- + +## message list-unread-conversations — 获取未读会话列表 + +获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。 + +``` +Usage: + dws chat message list-unread-conversations [flags] +Example: + dws chat message list-unread-conversations + dws chat message list-unread-conversations --count 20 +Flags: + --count int 返回未读会话条数(可选) +``` + +--- + +## message search — 按关键词搜索消息 + +在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。 + +``` +Usage: + dws chat message search [flags] +Example: + dws chat message search --keyword "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 --keyword "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 +Flags: + --keyword string 搜索关键词 (必填) + --group string 群聊 openconversation_id(可选,不传则搜索所有会话) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 100) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message info — 获取会话信息 + +获取指定群聊或单聊会话的详情信息。 + +``` +Usage: + dws chat message info [flags] +Example: + dws chat message info --id + dws chat message info --open-id +Flags: + --id string 群聊会话 ID openConversationId(与 --open-id 二选一) + --open-id string 用户 openDingTalkId(单聊时与 --id 二选一) + +注意: + - --id(群聊)和 --open-id(单聊用户 openDingTalkId)二选一 +``` + +--- + +## list-top-conversations — 拉取置顶会话列表 + +拉取当前用户的置顶会话列表。 + +``` +Usage: + dws chat list-top-conversations [flags] +Example: + dws chat list-top-conversations --limit 1000 + dws chat list-top-conversations --limit 1000 --cursor +Flags: + --limit int 每页返回数量(默认 1000) + --cursor int 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可 + - 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group ` 分别拉取每个会话内的消息 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor ``` --- @@ -126,161 +505,307 @@ Flags: Usage: dws chat bot search [flags] Example: - dws chat bot search --name "考勤" --format json + dws chat bot search --page 1 + dws chat bot search --page 1 --size 10 --name "日报" Flags: - --name string 机器人名称(模糊搜索) - --page int 页码(默认 1) - --size int 每页数量(默认 50) + --name string 按名称搜索 + --page int 页码,从1开始 (默认 1) + --size int 每页条数 (默认 50),别名: --limit +``` + +--- + +## bot create — 创建企业机器人 + +``` +Usage: + dws chat bot create [flags] +Example: + dws chat bot create --name "日报提醒机器人" --desc "负责每日日报提醒" +Flags: + --name string 机器人名称 (必填) + --desc string 机器人描述(可选) +``` + +--- + +## bot search-groups — 搜索机器人所在群 + +搜索指定机器人已加入的群列表。 + +``` +Usage: + dws chat bot search-groups [flags] +Example: + dws chat bot search-groups --keyword "项目" + dws chat bot search-groups --keyword "冲刺" --cursor +Flags: + --keyword string 搜索关键词 (必填) + --cursor string 分页游标(首页留空,翻页传返回的 cursor) ``` --- ## message send-by-bot — 机器人发消息 -支持两种模式:群聊发送 和 批量单聊发送,通过 `--group` 和 `--users` 互斥区分。 +群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。 -### 群聊发送 ``` Usage: dws chat message send-by-bot [flags] Example: - dws chat message send-by-bot --robot-code --group \ - --title "日报提醒" --text "请提交今日日报" --format json + dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..." + dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" Flags: - --robot-code string 机器人 code (必填) - --group string 群会话 ID (必填,与 --users 互斥) + --group string 群聊 openConversationId(群聊时必填) + --robot-code string 机器人 Code (必填) + --text string 消息内容 Markdown (必填) --title string 消息标题 (必填) - --text string 消息内容,支持 Markdown (必填) -``` + --users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填) -### 批量单聊发送 +注意: + - --group 与 --users 互斥,必须且只能指定其一 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) ``` -Usage: - dws chat message send-by-bot [flags] -Example: - dws chat message send-by-bot --robot-code --users "user1,user2" \ - --title "通知" --text "会议已取消" --format json -Flags: - --robot-code string 机器人 code (必填) - --users string 用户 ID 列表,逗号分隔,最多 20 个 (必填,与 --group 互斥) - --title string 消息标题 (必填) - --text string 消息内容,支持 Markdown (必填) -``` - -> ⚠️ `--group` 和 `--users` 互斥:群聊用 `--group`,单聊用 `--users`,不能同时传。 --- ## message recall-by-bot — 机器人撤回消息 -支持两种模式:群聊撤回 和 批量单聊撤回。 +群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。 -### 群聊撤回 ``` Usage: dws chat message recall-by-bot [flags] Example: - dws chat message recall-by-bot --robot-code --group \ - --keys "key1,key2" --format json + dws chat message recall-by-bot --robot-code --group --keys + dws chat message recall-by-bot --robot-code --keys key1,key2 Flags: - --robot-code string 机器人 code (必填) - --group string 群会话 ID (必填,与批量单聊互斥) - --keys string 消息 key 列表,逗号分隔 (必填) + --group string 群聊 openConversationId(群聊撤回时必填) + --keys string 消息 processQueryKey 列表,逗号分隔 (必填) + --robot-code string 机器人 Code (必填) ``` -### 批量单聊撤回 -``` -Usage: - dws chat message recall-by-bot [flags] -Example: - dws chat message recall-by-bot --robot-code --keys "key1,key2" --format json -Flags: - --robot-code string 机器人 code (必填) - --keys string 消息 key 列表,逗号分隔 (必填) -``` - -> ⚠️ 消息 key 从 `send-by-bot` 返回结果中提取。 - --- ## message send-by-webhook — 自定义机器人 Webhook 发消息 +@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。 + ``` Usage: dws chat message send-by-webhook [flags] Example: - dws chat message send-by-webhook --token \ - --title "告警" --text "CPU 使用率超过 90%" --format json + dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all + dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785 Flags: - --token string 自定义机器人 Webhook Token (必填) - --title string 消息标题 (必填) + --at-all @ 所有人 + --at-mobiles string @ 指定手机号,逗号分隔 + --at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId) --text string 消息内容 (必填) - --at-all @所有人 - --at-mobiles string @指定手机号列表,逗号分隔 - --at-users string @指定用户 ID 列表,逗号分隔 + --title string 消息标题 (必填) + --token string Webhook Token (必填) ``` --- ## 意图判断 -- 用户说"搜索一个群" → `search` -- 用户说"帮我建个群" → `group create` -- 用户说"看看群里有谁" → `group members list` -- 用户说"把张三拉进群" → 先 `contact user search` 获取 userId,再 `group members add` -- 用户说"把张三移出群" → 先 `contact user search` 获取 userId,再 `group members remove`(⚠️ 需确认) -- 用户说"改一下群名" → `group rename` -- 用户说"让机器人在群里发通知" → `message send-by-bot --group` -- 用户说"机器人给张三发消息" → 先 `contact user search` 获取 userId,再 `message send-by-bot --users` -- 用户说"通过 Webhook 发告警" / 用户有 Webhook Token → `message send-by-webhook` -- 用户说"撤回机器人消息" → `message recall-by-bot` -- 用户说"查一下我的机器人" → `bot search` -- 用户说"把机器人加到群里" → `group members add-bot` +用户说"建群/创建群聊" → `chat group create` +用户说"创建企业全员群/组织群" → `chat group create-org` +用户说"搜索群/找群" → `chat search` +用户说"群成员/看群里有谁" → `chat group members list` +用户说"拉人进群/加群成员" → `chat group members add` +用户说"踢人/移除群成员" → `chat group members remove` +用户说"加机器人到群" → `chat group members add-bot` +用户说"改群名" → `chat group rename` +用户说"聊天记录/会话消息/拉取会话" → `chat message list` +用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊) +用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用) +用户说"@我的消息/at我的/提及我的" → `chat message list-mentions` +用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations` +用户说"发群消息(以个人身份)" → `chat message send --group` +用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时) +用户说"发个人消息/个人通知" → `chat message send-personal`(⚠️ 敏感操作,需确认) +用户说"机器人发消息/机器人群发" → `chat message send-by-bot` +用户说"机器人撤回消息" → `chat message recall-by-bot` +用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook` +用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies` +用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all` +用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused` +用户说"查看我的机器人" → `chat bot search` +用户说"创建机器人" → `chat bot create` +用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search` +用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common` +用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations` +用户说"获取会话信息/会话详情" → `chat message info` +用户说"机器人在哪些群/机器人的群" → `chat bot search-groups` -**关键区分**: `send-by-bot`(企业内部机器人,需 robotCode) vs `send-by-webhook`(自定义机器人 Webhook,需 token) +关键区分: +- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页 +- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用) +- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用) +- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群) +- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`) +- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令 +- `chat message list-topic-replies` — 拉取群话题的回复消息列表 +- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页 +- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页 +- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息 +- `chat message send-personal` — 发送个人消息,支持通过 openConversationId 或 openDingTalkId 指定目标(⚠️ 敏感操作) +- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群) +- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在) +- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag +- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息 +- `chat message recall-by-bot` — 通过机器人撤回已发送的消息 +- `chat message info` — 获取指定会话的详情信息 +- `chat bot create` — 创建新的企业机器人 +- `chat bot search-groups` — 搜索机器人所在群列表 ## 核心工作流 ```bash -# ── 工作流: 建群并添加机器人 ── +# 1. 搜索群 — 提取 openconversation_id +dws chat search --query "项目冲刺" --format json -# 1. 搜索同事 userId -dws contact user search --keyword "张三" --format json +# 2. 拉取群消息 +dws chat message list --group --time "2025-03-01 00:00:00" --format json -# 2. 创建群 -dws chat group create --name "项目群" --users , --format json +# 2b. 拉取未读会话列表 +dws chat message list-unread-conversations --count 20 --format json -# 3. 搜索机器人 -dws chat bot search --format json +# 3. 以个人身份发送群消息 +dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" --format json -# 4. 添加机器人到群 -dws chat group members add-bot --id --robot-code --format json +# 4. 以个人身份单聊(通过 userId) +dws chat message send --user "你好" --format json + +# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用) +dws chat message send --open-dingtalk-id "你好" --format json + +# 5. 机器人发群消息(Markdown) +dws chat message send-by-bot --robot-code \ + --group --title "日报" --text "## 今日完成..." --format json + +# 6. 机器人单聊发消息 +dws chat message send-by-bot --robot-code \ + --users userId1,userId2 --title "提醒" --text "请提交周报" --format json + +# 7. Webhook 发告警 +dws chat message send-by-webhook --token \ + --title "告警" --text "CPU 超 90%" --at-all --format json ``` -```bash -# ── 工作流: 机器人群发消息 ── +## 复合工作流 -# 1. 搜索可用机器人 +### 机器人发消息后撤回(完整流程) + +撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。 + +```bash +# Step 1: 查我的机器人 — 提取 robot-code dws chat bot search --format json -# 2. 发送群消息 -dws chat message send-by-bot --robot-code --group \ +# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey +dws chat message send-by-bot --robot-code --group \ --title "通知" --text "内容" --format json + +# Step 3: 用同一个 robot-code + processQueryKey 撤回 +dws chat message recall-by-bot --robot-code --group \ + --keys --format json ``` -```bash -# ── 工作流: Webhook 告警 ── +### 创建并使用机器人(完整流程) -# 直接通过 Webhook Token 发送 -dws chat message send-by-webhook --token \ - --title "告警" --text "服务异常" --at-all --format json +```bash +# Step 1: 创建机器人 +dws chat bot create --name "项目提醒机器人" --desc "项目状态提醒" --format json + +# Step 2: 搜索群 — 提取 openConversationId +dws chat search --query "项目群" --format json + +# Step 3: 将机器人添加到群 +dws chat group members add-bot --id --robot-code --format json + +# Step 4: 机器人发消息 +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "请及时更新项目状态" --format json +``` + +### 机器人 @指定人发群消息 + +`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。 + +```bash +# Step 1: 搜人获取 userId +dws aisearch person --keyword "张三" --dimension name --format json + +# Step 2: 取 userId 发送(注意 text 中的占位符) +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json +``` + +### 发送图片/文件消息(跨产品: drive → chat) + +```bash +# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证 +dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json + +# Step 2: HTTP PUT 上传文件到 OSS +curl -X PUT -T "截图.png" "" + +# Step 3: 提交上传 — 获取 dentryUuid +dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id --format json + +# Step 4: 获取下载链接 +dws drive download --file-id --format json + +# Step 5: 用 Markdown 图片语法发送 +dws chat message send --group \ + --text "![截图](下载链接)" --format json ``` ## 上下文传递表 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `search` | openConversationId | `group members` / `group rename` / `group members add` / `send-by-bot --group` | -| `group create` | openConversationId | 同上 | -| `bot search` | robotCode | `send-by-bot` / `recall-by-bot` / `add-bot` | -| `message send-by-bot` | processQueryKey | `recall-by-bot --keys` | +| `chat search` | `openConversationId` | message send/list、group members 等的 --group | +| `chat group create` | `openConversationId` | 同上 | +| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor | +| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id | +| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id | +| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code | +| `chat bot create` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code | +| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | +| `chat message search` | `nextCursor` | 下次 message search 的 --cursor | +| `chat search-common` | `openConversationId` | message send/list 等的 --group | +| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 | + +## 注意事项 + +- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取 +- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id` +- `chat message send-personal` 为敏感操作(isSensitive),执行前需用户明确确认 +- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor +- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一 +- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识 +- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部 +- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数 +- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话 +- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑 +- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor` +- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一 +- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) +- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | +| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | + +## 相关产品 + +- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id +- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息