Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f19a3ccfa5 | ||
|
|
89c5038446 | ||
|
|
4f915e4e2c | ||
|
|
ec03b7cca3 | ||
|
|
e0544579d2 | ||
|
|
ce43280c11 | ||
|
|
74ca40c197 | ||
|
|
cfaa673863 | ||
|
|
3bc6c31a2d |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.7%"><title>coverage: 48.7%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 49.8%"><title>coverage: 49.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">49.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">49.8%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+254
@@ -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.
|
||||
|
||||
@@ -358,16 +358,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
|
||||
| AITable | `aitable` | 37 | `base` `table` `record` `field` `attachment` `template` `chart` `dashboard` `export` `import` `view` | Full CRUD for bases/tables/records/fields; charts/dashboards; data import/export; views; templates |
|
||||
| Doc | `doc` | 16 | `search` `list` `info` `read` `create` `update` `upload` `download` `folder` `block` `comment` | Search, read, create/update documents; block-level editing; file upload/download; comments |
|
||||
| Minutes | `minutes` | 22 | `list` `get` `update` `record` `hot-word` `mind-graph` `replace-text` `speaker` `upload` | List/search AI meeting transcripts; summaries, transcriptions, todos, mind-maps; recording control; speaker management, hot-words, file upload |
|
||||
| Workbench | `workbench` | 2 | `app` | Batch query app details |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
|
||||
|
||||
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
> 141 commands across 14 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
|
||||
`mail` (email) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+4
-15
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-87
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+40
-121
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)时使用。
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档/钉钉文档/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、查询听记纪要时使用。
|
||||
cli_version: ">=1.0.6"
|
||||
---
|
||||
|
||||
@@ -24,7 +24,7 @@ cli_version: ">=1.0.6"
|
||||
|
||||
| 产品 | 用途 | 参考文件 |
|
||||
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `aitable` | AI表格:表格/数据表/字段/记录增删改查/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/附件/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `approval` | 审批:审批表单/发起实例/审批/撤销 | [simple.md](./references/products/simple.md) |
|
||||
| `attendance` | 考勤:打卡记录/排班查询 | [attendance.md](./references/products/attendance.md) |
|
||||
| `calendar` | 日历:日程/参与者/会议室/闲忙查询 | [calendar.md](./references/products/calendar.md) |
|
||||
@@ -32,6 +32,8 @@ cli_version: ">=1.0.6"
|
||||
| `contact` | 通讯录:用户查询(当前用户/搜索/详情)/部门查询(搜索/子部门/成员列表) | [contact.md](./references/products/contact.md) |
|
||||
| `devdoc` | 开放平台文档:搜索开发文档 | [simple.md](./references/products/simple.md) |
|
||||
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
|
||||
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论 | [doc.md](./references/products/doc.md) |
|
||||
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词 | [minutes.md](./references/products/minutes.md) |
|
||||
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
|
||||
| `todo` | 待办:创建(含优先级/截止时间)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
| `workbench` | 工作台:应用管理 | [workbench.md](./references/products/workbench.md) |
|
||||
@@ -46,6 +48,8 @@ cli_version: ">=1.0.6"
|
||||
用户提到"通讯录/同事/部门/组织架构" → `contact`
|
||||
用户提到"开发/API/调用错误 文档" → `devdoc`
|
||||
用户提到"DING/紧急消息/电话提醒" → `ding`
|
||||
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论" → `doc`
|
||||
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
|
||||
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
|
||||
用户提到"待办/TODO/任务提醒" → `todo`
|
||||
用户提到"工作台/应用管理" → `workbench`
|
||||
@@ -69,6 +73,8 @@ cli_version: ">=1.0.6"
|
||||
| `calendar` | `participant delete` | 移除日程参与者 |
|
||||
| `calendar` | `room delete` | 取消会议室预定 |
|
||||
| `chat` | `group members remove` | 移除群成员 |
|
||||
| `doc` | `delete` | 删除钉钉文档(不可恢复) |
|
||||
| `doc` | `block delete` | 删除文档块 |
|
||||
| `todo` | `task delete` | 删除待办 |
|
||||
|
||||
### 确认流程
|
||||
|
||||
@@ -31,9 +31,9 @@ Flags:
|
||||
|
||||
> 📎 **操作后返回文档链接**:遍历返回的每个 base,拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
|
||||
|
||||
> ⚠️ **注意**:`base list` 仅返回**最近访问过**的 Base,不是全部 Base。
|
||||
> 新创建的 Base 如果尚未在钉钉前端打开过,可能不会出现在此列表中。
|
||||
> 如需查找特定 Base,请使用 `base search`;如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
|
||||
> ⚠️ **重要**:`base list` 仅返回**最近访问过**的 Base,**不是全部 Base**。
|
||||
> 如需查找表格,**请优先使用 `base search`**;`base list` 仅作为浏览最近表格的辅助手段。
|
||||
> 如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
|
||||
|
||||
#### 搜索 AI 表格
|
||||
```
|
||||
@@ -56,6 +56,8 @@ Flags:
|
||||
--base-id string Base 唯一标识 (必填)
|
||||
```
|
||||
|
||||
> 💡 **用户提供 URL 时**:如果用户给出了链接如 `https://alidocs.dingtalk.com/i/nodes/ABC123`,请提取末尾的 `ABC123` 作为 `--base-id` 传入。详见下方「URL → baseId 提取」章节。
|
||||
|
||||
返回 baseName、tables、dashboards 的 summary 信息(不含字段与记录详情)。
|
||||
后续如需 tableId,优先从这里读取。
|
||||
|
||||
@@ -186,15 +188,24 @@ Flags:
|
||||
Usage:
|
||||
dws aitable field create [flags]
|
||||
Example:
|
||||
# 单字段模式
|
||||
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]'
|
||||
--name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}'
|
||||
|
||||
# 批量模式
|
||||
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--fields string 待新增字段 JSON 数组,至少 1 个,单次最多 15 个 (必填)
|
||||
--name string 单字段名称(与 --type 配合使用,替代 --fields)
|
||||
--type string 单字段类型(参考 table create 字段类型)
|
||||
--config string 单字段配置 JSON(可选,如 options)
|
||||
--fields string 批量新增字段 JSON 数组,单次最多 15 个(与 --name/--type 二选一)
|
||||
--table-id string Table ID (必填)
|
||||
```
|
||||
|
||||
允许部分成功,返回结果逐项标明成功/失败状态。
|
||||
`--name/--type/--config` 为单字段模式;`--fields` 为批量模式;两种模式二选一。
|
||||
|
||||
#### 更新字段
|
||||
```
|
||||
@@ -236,13 +247,13 @@ Usage:
|
||||
Example:
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "关键词" --limit 50
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--cursor string 分页游标,首次不传
|
||||
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
|
||||
--filters string 结构化过滤条件 JSON
|
||||
--keyword string 全文关键词搜索
|
||||
--query string 全文关键词搜索
|
||||
--limit int 单次最大记录数,默认 100,最大 100
|
||||
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
|
||||
--sort string 排序条件 JSON 数组
|
||||
@@ -251,6 +262,10 @@ Flags:
|
||||
|
||||
两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。
|
||||
|
||||
> ⚠️ **排序参数规范(关键)**:`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),**不要使用 `order`**。
|
||||
>
|
||||
> 正确示例:`--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]'`
|
||||
|
||||
filters 结构:`{"operator":"and|or","operands":[{"operator":"<op>","operands":["<fieldId>","<value>"]}]}`
|
||||
|
||||
> 💡 **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。
|
||||
@@ -365,10 +380,48 @@ Flags:
|
||||
|
||||
> 📎 **模板预览地址**:`https://docs.dingtalk.com/table/template/{templateId}`
|
||||
|
||||
## 复杂操作
|
||||
|
||||
### 仪表盘 / 图表(建议顺序)
|
||||
|
||||
```bash
|
||||
# 1) 先看配置模板(JSONC)
|
||||
dws aitable dashboard config-example --format json
|
||||
dws aitable chart widgets-example --format json
|
||||
|
||||
# 2) 先拿 dashboard,再拿 chart 详情
|
||||
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
|
||||
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
|
||||
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
|
||||
|
||||
### 导出数据(两阶段轮询)
|
||||
|
||||
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
|
||||
|
||||
```bash
|
||||
# 第一步:创建任务(按 scope 传必要参数)
|
||||
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
|
||||
|
||||
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
|
||||
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
|
||||
```
|
||||
|
||||
参数约束
|
||||
|
||||
- `scope=all`:只需 `base-id`
|
||||
- `scope=table`:必须 `table-id`
|
||||
- `scope=view`:必须同时 `table-id + view-id`
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"表格/多维表/AI表格":
|
||||
- 查看/列表 → `base list`
|
||||
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
|
||||
- 搜索 → `base search`
|
||||
- 详情 → `base get`
|
||||
- 创建 → `base create`
|
||||
@@ -433,6 +486,63 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
|
||||
- records 的 cells key 是 fieldId,不是字段名称
|
||||
|
||||
## `--filters` 筛选语法排错与使用规范(极易出错)
|
||||
|
||||
调用 `record query` 时,如果条件筛选**完全失效(查询返回了所有记录)**,通常是因为 `--filters` JSON 语法错误,API 默默丢弃了不合规的 filter。
|
||||
|
||||
**强制规则:**
|
||||
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符。
|
||||
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中。
|
||||
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值,而不是 ID。
|
||||
4. **内层比较操作符语义**:支持 `eq`(等于)、`not_eq`(不等于)、`contain`(包含/模糊搜索)、`not_contain`(不包含)、`gt/gte`(大于/大于等于)、`lt/lte`(小于/小于等于)、`is_empty/is_not_empty`(为空/不为空,对应 operands 内只传单个 fieldId)。
|
||||
|
||||
**精简防呆模板与 4 种衍生情况**
|
||||
```json
|
||||
{
|
||||
"operator": "and", // 情况 4 (OR 查询): 这里改为 "or"
|
||||
"operands": [
|
||||
{
|
||||
"operator": "eq", // 情况 3 (文本包含): 这里改为 "contain"
|
||||
"operands": ["fld_state", "进行中"] // 情况 1 (基础等于)
|
||||
}
|
||||
// 情况 2 (多条件 AND): 在此行增加类似 {"operator":"eq","operands":["fld_priority","高"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**错误示例 1:缺失根节点 and/or**(API 将忽略该 filter,返回全表)
|
||||
```json
|
||||
{"operator":"eq","operands":["fldXXX","本科"]}
|
||||
```
|
||||
|
||||
**错误示例 2:传入选项 ID 而非名称**(可能导致匹配不到 0 记录)
|
||||
```json
|
||||
{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]}
|
||||
```
|
||||
|
||||
## URL → baseId 提取
|
||||
|
||||
用户经常通过钉钉链接指定表格,链接格式为:
|
||||
|
||||
```
|
||||
https://alidocs.dingtalk.com/i/nodes/{baseId}
|
||||
https://alidocs.dingtalk.com/i/nodes/{baseId}?xxx=yyy
|
||||
```
|
||||
|
||||
**处理规则**(必须严格遵守):
|
||||
1. 当用户提供了包含 `alidocs.dingtalk.com/i/nodes/` 的 URL 时,提取 `/nodes/` 后的路径段作为 `baseId`
|
||||
2. 去掉尾部的查询参数(`?` 及其后内容)和尾部斜杠
|
||||
3. 将提取得到的 ID 传入 `--base-id` 参数
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户输入:帮我查看 https://alidocs.dingtalk.com/i/nodes/ABC123XYZ 这个表格
|
||||
→ 提取 baseId = ABC123XYZ
|
||||
→ 执行: dws aitable base get --base-id ABC123XYZ --format json
|
||||
```
|
||||
|
||||
> 💡 **注意**:URL 中的 nodeId 在 AI 表格场景下等同于 baseId,可以直接作为 `--base-id` 使用。
|
||||
|
||||
### cells 写入/读取格式速查
|
||||
|
||||
| 字段类型 | 写入格式 | 读取返回格式 |
|
||||
@@ -450,3 +560,7 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
| group | `[{"cid":"xxx"}]` (注意: key 是 cid,不是 openConversationId) | 同写入 |
|
||||
|
||||
- 详见 [field-rules.md](../field-rules.md) 和 [error-codes.md](../error-codes.md)
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [doc](./doc.md) — 富文本文档编辑,不是结构化数据表格
|
||||
|
||||
+652
-127
@@ -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 <openConversationId> --format json
|
||||
dws chat group members list --id <openconversation_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 <openConversationId> --users userId1,userId2 --format json
|
||||
dws chat group members add --id <openconversation_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 <openConversationId> --users userId1,userId2 --format json
|
||||
dws chat group members remove --id <openconversation_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 <openConversationId> --robot-code <robotCode> --format json
|
||||
dws chat group members add-bot --robot-code <robot-code> --id <openconversation_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 <openConversationId> --name "新群名" --format json
|
||||
dws chat group rename --id <openconversation_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 <nextCursor>
|
||||
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] [<text>]
|
||||
Example:
|
||||
dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
|
||||
dws chat message send --group <openconversation_id> "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>
|
||||
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 <openConversationId> --content "你好" --type text
|
||||
dws chat message send-personal --open-id <openDingTalkId> --content "消息内容" --type text
|
||||
dws chat message send-personal --id <openConversationId> --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 <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --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 <openconversation_id> --topic-id <topicId>
|
||||
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId> --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 <userId> --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 <openDingTalkId> --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 <openconversation_id> --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 <nextCursor>
|
||||
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 <openconversation_id> --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 <openConversationId>
|
||||
dws chat message info --open-id <openDingTalkId>
|
||||
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 <nextCursor>
|
||||
Flags:
|
||||
--limit int 每页返回数量(默认 1000)
|
||||
--cursor int 分页游标(首次不传或传 0,翻页传 nextCursor)
|
||||
|
||||
注意:
|
||||
- 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可
|
||||
- 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group <openConversationId>` 分别拉取每个会话内的消息
|
||||
- 翻页: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 <nextCursor>
|
||||
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 <code> --group <openConversationId> \
|
||||
--title "日报提醒" --text "请提交今日日报" --format json
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
|
||||
dws chat message send-by-bot --robot-code <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 <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 <code> --group <openConversationId> \
|
||||
--keys "key1,key2" --format json
|
||||
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> --keys <process-query-key>
|
||||
dws chat message recall-by-bot --robot-code <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 <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 <robotToken> \
|
||||
--title "告警" --text "CPU 使用率超过 90%" --format json
|
||||
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
|
||||
dws chat message send-by-webhook --token <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 <openconversation_id> --time "2025-03-01 00:00:00" --format json
|
||||
|
||||
# 2. 创建群
|
||||
dws chat group create --name "项目群" --users <userId1>,<userId2> --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 <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
|
||||
|
||||
# 4. 添加机器人到群
|
||||
dws chat group members add-bot --id <openConversationId> --robot-code <code> --format json
|
||||
# 4. 以个人身份单聊(通过 userId)
|
||||
dws chat message send --user <userId> "你好" --format json
|
||||
|
||||
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
|
||||
|
||||
# 5. 机器人发群消息(Markdown)
|
||||
dws chat message send-by-bot --robot-code <robot-code> \
|
||||
--group <openconversation_id> --title "日报" --text "## 今日完成..." --format json
|
||||
|
||||
# 6. 机器人单聊发消息
|
||||
dws chat message send-by-bot --robot-code <robot-code> \
|
||||
--users userId1,userId2 --title "提醒" --text "请提交周报" --format json
|
||||
|
||||
# 7. Webhook 发告警
|
||||
dws chat message send-by-webhook --token <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 <code> --group <groupId> \
|
||||
# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
|
||||
--title "通知" --text "内容" --format json
|
||||
|
||||
# Step 3: 用同一个 robot-code + processQueryKey 撤回
|
||||
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> \
|
||||
--keys <processQueryKey> --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
# ── 工作流: Webhook 告警 ──
|
||||
### 创建并使用机器人(完整流程)
|
||||
|
||||
# 直接通过 Webhook Token 发送
|
||||
dws chat message send-by-webhook --token <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 <openConversationId> --robot-code <robotCode> --format json
|
||||
|
||||
# Step 4: 机器人发消息
|
||||
dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> \
|
||||
--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 <robot-code> --group <openconversation_id> \
|
||||
--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" "<upload-info 返回的上传 URL>"
|
||||
|
||||
# Step 3: 提交上传 — 获取 dentryUuid
|
||||
dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id <uploadId> --format json
|
||||
|
||||
# Step 4: 获取下载链接
|
||||
dws drive download --file-id <dentryUuid> --format json
|
||||
|
||||
# Step 5: 用 Markdown 图片语法发送
|
||||
dws chat message send --group <openconversation_id> \
|
||||
--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 图片/文件消息
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
# 文档 (doc) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 搜索文档
|
||||
```
|
||||
Usage:
|
||||
dws doc search [flags]
|
||||
Example:
|
||||
dws doc search --query "会议纪要"
|
||||
dws doc search
|
||||
dws doc search --extensions pdf,docx
|
||||
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
|
||||
dws doc search --creator-uids uid1,uid2
|
||||
dws doc search --workspace-ids wsId1,wsId2
|
||||
Flags:
|
||||
--query string 搜索关键词 (不传则返回最近访问)
|
||||
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。支持的在线文档类型后缀名: adoc=文字, axls=表格, appt=演示文稿, awbd=白板, adraw=画板, amind=脑图, able=多维表格, aform=收集表
|
||||
--created-from int 创建时间起始 (毫秒时间戳,含)
|
||||
--created-to int 创建时间截止 (毫秒时间戳,含)
|
||||
--visited-from int 访问时间起始 (毫秒时间戳,含)
|
||||
--visited-to int 访问时间截止 (毫秒时间戳,含)
|
||||
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
|
||||
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
|
||||
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
|
||||
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
|
||||
--page-size int 每页数量 (默认 10,最大 30)
|
||||
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
|
||||
```
|
||||
|
||||
### 遍历文件列表
|
||||
```
|
||||
Usage:
|
||||
dws doc list [flags]
|
||||
Example:
|
||||
dws doc list
|
||||
dws doc list --folder <FOLDER_ID>
|
||||
dws doc list --workspace <WS_ID> --page-size 20
|
||||
Flags:
|
||||
--folder string 文件夹 ID 或 URL
|
||||
--workspace string 知识库 ID
|
||||
--page-size int 每页数量 (默认 50,最大 50)
|
||||
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
|
||||
```
|
||||
|
||||
### 获取文档元信息
|
||||
```
|
||||
Usage:
|
||||
dws doc info [flags]
|
||||
Example:
|
||||
dws doc info --node <DOC_ID>
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
### 读取文档内容
|
||||
```
|
||||
Usage:
|
||||
dws doc read [flags]
|
||||
Example:
|
||||
dws doc read --node <DOC_ID>
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
### 创建文档
|
||||
```
|
||||
Usage:
|
||||
dws doc create [flags]
|
||||
Example:
|
||||
dws doc create --name "项目周报"
|
||||
dws doc create --name "Q1 总结" --markdown "# Q1 总结" --folder <FOLDER_ID>
|
||||
dws doc create --name "知识库文档" --workspace <WS_ID>
|
||||
Flags:
|
||||
--name string 文档名称 (必填)
|
||||
--folder string 目标文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID
|
||||
--markdown string 文档初始 Markdown 内容
|
||||
```
|
||||
|
||||
### 更新文档内容
|
||||
```
|
||||
Usage:
|
||||
dws doc update [flags]
|
||||
Example:
|
||||
dws doc update --node <DOC_ID> --markdown "# 追加内容" --mode append
|
||||
dws doc update --node <DOC_ID> --markdown "# 完整替换" --mode overwrite
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--markdown string Markdown 内容 (必填)
|
||||
--mode string 更新模式: overwrite=覆盖, append=追加 (默认 append)
|
||||
```
|
||||
|
||||
### 上传文件到钉钉文档或钉钉知识库
|
||||
```
|
||||
Usage:
|
||||
dws doc upload [flags]
|
||||
Example:
|
||||
dws doc upload --file ./report.pdf
|
||||
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
|
||||
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
|
||||
Flags:
|
||||
--file string 本地文件路径 (必填)
|
||||
--name string 文件显示名称 (默认使用文件名)
|
||||
--folder string 目标文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID
|
||||
--convert 是否转换为钉钉在线文档
|
||||
```
|
||||
|
||||
### 下载文件到本地
|
||||
```
|
||||
Usage:
|
||||
dws doc download [flags]
|
||||
Example:
|
||||
dws doc download --node <NODE_ID>
|
||||
dws doc download --node <NODE_ID> --output ./report.pdf
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ~/downloads/
|
||||
Flags:
|
||||
--node string 文件节点 ID 或 URL (必填)
|
||||
--output string 本地保存路径 (文件路径或目录,必填)
|
||||
```
|
||||
|
||||
### 创建文件夹
|
||||
```
|
||||
Usage:
|
||||
dws doc folder create [flags]
|
||||
Example:
|
||||
dws doc folder create --name "项目资料"
|
||||
dws doc folder create --name "子文件夹" --folder <PARENT_FOLDER_ID>
|
||||
Flags:
|
||||
--name string 文件夹名称 (必填)
|
||||
--folder string 父文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID
|
||||
```
|
||||
|
||||
### 查询块元素
|
||||
```
|
||||
Usage:
|
||||
dws doc block list [flags]
|
||||
Example:
|
||||
dws doc block list --node <DOC_ID>
|
||||
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
|
||||
dws doc block list --node <DOC_ID> --block-type heading
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--start-index int 起始位置 (从 0 开始)
|
||||
--end-index int 终止位置 (含)
|
||||
--block-type string 按块类型过滤
|
||||
```
|
||||
|
||||
### 插入块元素
|
||||
```
|
||||
Usage:
|
||||
dws doc block insert [flags]
|
||||
Example:
|
||||
dws doc block insert --node <DOC_ID> --text "这是一段文字"
|
||||
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
|
||||
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
|
||||
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--text string 快捷: 段落文本内容
|
||||
--heading string 快捷: 标题文本
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级)
|
||||
--index int 参照位置索引 (从 0 开始)
|
||||
--where string 插入方向: before / after (默认 after)
|
||||
--ref-block string 参照块 ID (优先级高于 --index)
|
||||
```
|
||||
|
||||
### 更新块元素
|
||||
```
|
||||
Usage:
|
||||
dws doc block update [flags]
|
||||
Example:
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--block-id string 目标块 ID (必填)
|
||||
--text string 快捷: 段落文本内容
|
||||
--heading string 快捷: 标题文本
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级)
|
||||
```
|
||||
|
||||
### 删除块元素
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws doc block delete [flags]
|
||||
Example:
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--block-id string 目标块 ID (必填)
|
||||
```
|
||||
|
||||
### 查询文档评论列表
|
||||
```
|
||||
Usage:
|
||||
dws doc comment list [flags]
|
||||
Example:
|
||||
dws doc comment list --node <DOC_ID>
|
||||
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
|
||||
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--page-size int 每页返回的评论数量,默认 50,最大 50
|
||||
--next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
|
||||
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
|
||||
```
|
||||
|
||||
### 创建文档评论
|
||||
```
|
||||
Usage:
|
||||
dws doc comment create [flags]
|
||||
Example:
|
||||
dws doc comment create --node <DOC_ID> --content "这里需要修改"
|
||||
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--content string 评论的文字内容,纯文本 (必填)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
### 回复文档评论
|
||||
```
|
||||
Usage:
|
||||
dws doc comment reply [flags]
|
||||
Example:
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
|
||||
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
|
||||
--emoji 设为 true 时作为表情贴图回复 (默认 false)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
## URL 识别与 DOC_ID 提取
|
||||
|
||||
当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。
|
||||
|
||||
### 支持的 URL 格式
|
||||
|
||||
| 格式 | 示例 | DOC_ID 提取方式 |
|
||||
|------|------|----------------|
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
|
||||
|
||||
### 提取规则
|
||||
|
||||
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
|
||||
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
|
||||
### 处理流程
|
||||
|
||||
```
|
||||
用户输入含 alidocs.dingtalk.com URL
|
||||
→ 提取 DOC_ID(URL 路径最后一段)
|
||||
→ 结合用户意图选择命令(默认 read)
|
||||
→ 将 DOC_ID 传给 --node 参数
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"找文档/搜文档/最近文档":
|
||||
- 搜索 → `search`
|
||||
- 浏览 → `list`
|
||||
|
||||
用户说"看文档/读内容/文档内容":
|
||||
- 读取 → `read` (需文档 ID 或 URL)
|
||||
- 元信息 → `info`
|
||||
|
||||
用户说"写文档/创建文档":
|
||||
- 新建 → `create`
|
||||
- 追加内容 → `update --mode append`
|
||||
- 覆盖替换 → `update --mode overwrite`
|
||||
|
||||
用户说"建文件夹/新建目录":
|
||||
- 创建 → `folder create`
|
||||
|
||||
用户说"上传文件/传文件/上传到文档/上传到知识库":
|
||||
- 上传 → `upload`(需本地文件路径)
|
||||
- 上传并转换 → `upload --convert`
|
||||
|
||||
用户说"下载文件/导出文件/下载到本地":
|
||||
- 下载 → `download`(需文件节点 ID 或 URL)
|
||||
|
||||
用户说"编辑块/改段落/插入标题/删除块":
|
||||
- 查看结构 → `block list`
|
||||
- 插入 → `block insert`
|
||||
- 修改 → `block update`
|
||||
- 删除 → `block delete`
|
||||
|
||||
**用户直接粘贴文档 URL(无其他指令)**:
|
||||
- 默认 → `read`(读取文档内容)
|
||||
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
|
||||
|
||||
**用户粘贴 URL + 附加指令**:
|
||||
- "帮我看看这个文档" → `read`
|
||||
- "这个文档的信息" → `info`
|
||||
- "往这个文档追加内容" → `update --mode append`
|
||||
- "编辑这个文档的标题" → `block update`
|
||||
|
||||
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 1: 浏览并阅读文档 ──
|
||||
|
||||
# 1. 浏览我的文档根目录
|
||||
dws doc list --format json
|
||||
|
||||
# 2. 浏览子文件夹
|
||||
dws doc list --folder <FOLDER_ID> --format json
|
||||
|
||||
# 3. 获取文档元信息 (标题、类型、权限)
|
||||
dws doc info --node <DOC_ID> --format json
|
||||
|
||||
# 4. 读取文档内容 (Markdown 格式)
|
||||
dws doc read --node <DOC_ID> --format json
|
||||
|
||||
# ── 工作流 2: 创建文档并写入内容 ──
|
||||
|
||||
# 1. (可选) 创建文件夹 — 提取 nodeId
|
||||
dws doc folder create --name "项目资料" --format json
|
||||
|
||||
# 2. 创建文档 — 提取 nodeId
|
||||
dws doc create --name "项目周报" --folder <FOLDER_ID> --format json
|
||||
|
||||
# 3. 写入内容 (追加模式)
|
||||
dws doc update --node <DOC_ID> --markdown "# 本周总结\n\n- 完成了 A\n- 推进了 B" --mode append --format json
|
||||
|
||||
# ── 工作流 3: 一步创建带内容的文档 ──
|
||||
|
||||
dws doc create --name "会议纪要" --markdown "# 会议纪要\n\n## 议题\n\n1. ..." --format json
|
||||
|
||||
# ── 工作流 4: 上传本地文件到钉钉文档/知识库 ──
|
||||
|
||||
# 1. 上传到"我的文档"根目录
|
||||
dws doc upload --file ./report.pdf
|
||||
|
||||
# 2. 上传到指定文件夹
|
||||
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
|
||||
|
||||
# 3. 上传到知识库并转换为在线文档
|
||||
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
|
||||
|
||||
# ── 工作流 5: 下载文件到本地 ──
|
||||
|
||||
# 1. 下载到当前目录 (自动推断文件名)
|
||||
dws doc download --node <NODE_ID>
|
||||
|
||||
# 2. 下载到指定路径
|
||||
dws doc download --node <NODE_ID> --output ./report.pdf
|
||||
|
||||
# 3. 下载到指定目录 (自动推断文件名)
|
||||
dws doc download --node <NODE_ID> --output ~/downloads/
|
||||
|
||||
# ── 工作流 6: 块级精细编辑 ──
|
||||
|
||||
# 1. 查看文档块结构 — 获取 blockId
|
||||
dws doc block list --node <DOC_ID> --format json
|
||||
|
||||
# 2. 在文档末尾插入段落
|
||||
dws doc block insert --node <DOC_ID> --text "新增内容"
|
||||
|
||||
# 3. 在指定块之前插入标题
|
||||
dws doc block insert --node <DOC_ID> --heading "新章节" --level 2 --ref-block <BLOCK_ID> --where before
|
||||
|
||||
# 4. 更新某个块的内容
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后的内容"
|
||||
|
||||
# 5. 删除块
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
|
||||
|
||||
# ── 工作流 7: 文档评论管理 ──
|
||||
|
||||
# 1. 查看文档的所有评论
|
||||
dws doc comment list --node <DOC_ID> --format json
|
||||
|
||||
# 2. 在文档上创建评论
|
||||
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
|
||||
|
||||
# 3. 创建评论并 @ 相关人
|
||||
# 先搜索用户: dws contact user search --query "张三" --format json → 提取 userId
|
||||
# 再将 userId 传入 --mention
|
||||
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
|
||||
|
||||
# 5. 用表情回复评论
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | `nodes[].nodeId` | read / info / update / block 操作的 --node |
|
||||
| `list` | folder 类型的 `nodeId` | list 的 --folder, create 的 --folder |
|
||||
| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 |
|
||||
| `create` | `nodeId` | update / block 操作的 --node |
|
||||
| `folder create` | `nodeId` | create / list / upload 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
|
||||
| `download` | 本地文件路径 | 下载后的文件保存位置 |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
| `comment create` | `commentKey` | comment reply 的 --comment-key |
|
||||
| `contact user search` | `userId` | comment create/reply 的 --mention |
|
||||
|
||||
## nodeId 双格式说明
|
||||
|
||||
所有 `--node` 参数同时支持两种格式,系统自动识别:
|
||||
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
|
||||
两种方式等价,以下命令效果相同:
|
||||
```bash
|
||||
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
|
||||
```
|
||||
|
||||
`--folder` 参数同样支持文件夹 URL 或 ID。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `update --mode overwrite` 会**清空原内容后重写**,⚠️ 谨慎使用;默认 `--mode append` (追加) 更安全
|
||||
- `read` 返回 Markdown 格式的文档内容,仅限有"下载"权限的文档
|
||||
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
|
||||
- `block list/insert/update/delete` 是块级精细编辑,适合结构化修改;简单内容追加建议用 `update --mode append`
|
||||
- `block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型 (table, callout 等) 使用 `--element` JSON
|
||||
- `markdown` 参数中的换行必须使用**真实换行符**(即实际的换行字符,Unicode `U+000A`),而不是字面量字符串 `\n`(反斜杠加字母 n)。在通过程序或大模型构造此参数时,请确保字符串在发送前已正确反转义。如果传入的是两个字符的字面量 `\n`,所有内容将渲染在同一行,导致标题、段落和表格格式全部错乱。
|
||||
- 块类型包括: paragraph, heading, blockquote, callout, columns, orderedList, unorderedList, table, sheet, attachment, slot
|
||||
- 关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
|
||||
- `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档
|
||||
- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作
|
||||
- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
|
||||
- [drive](./drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
|
||||
- [report](./report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
|
||||
@@ -0,0 +1,274 @@
|
||||
# AI听记 (minutes) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 查询我创建的听记列表
|
||||
```
|
||||
Usage:
|
||||
dws minutes list mine [flags]
|
||||
Example:
|
||||
dws minutes list mine
|
||||
dws minutes list mine --max 10
|
||||
dws minutes list mine --max 10 --next-token <nextToken>
|
||||
dws minutes list mine --query "周会"
|
||||
Flags:
|
||||
--max float 查询的听记篇数 (默认 10)
|
||||
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
|
||||
--query string 关键字筛选 (可选)
|
||||
--start string 开始时间 ISO-8601 (可选)
|
||||
--end string 结束时间 ISO-8601 (可选)
|
||||
```
|
||||
|
||||
查询我创建的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
|
||||
|
||||
### 查询他人共享给我的听记列表
|
||||
```
|
||||
Usage:
|
||||
dws minutes list shared [flags]
|
||||
Example:
|
||||
dws minutes list shared
|
||||
dws minutes list shared --max 20
|
||||
dws minutes list shared --max 5 --next-token <nextToken>
|
||||
Flags:
|
||||
--max float 查询的听记篇数 (默认 10)
|
||||
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
|
||||
--query string 关键字筛选 (可选)
|
||||
--start string 开始时间 ISO-8601 (可选)
|
||||
--end string 结束时间 ISO-8601 (可选)
|
||||
```
|
||||
|
||||
查询他人共享给我的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
|
||||
|
||||
### 查询我有权限访问的所有听记列表
|
||||
```
|
||||
Usage:
|
||||
dws minutes list all [flags]
|
||||
Example:
|
||||
dws minutes list all
|
||||
dws minutes list all --max 20
|
||||
dws minutes list all --query "周会" --max 20
|
||||
dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
|
||||
dws minutes list all --max 10 --next-token <nextToken>
|
||||
Flags:
|
||||
--end string 结束时间 ISO-8601 (可选)
|
||||
--query string 关键字筛选 (可选)
|
||||
--max float 查询的听记篇数 (默认 10)
|
||||
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
|
||||
--start string 开始时间 ISO-8601 (可选)
|
||||
```
|
||||
|
||||
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--next-token` 进行分页查询。
|
||||
|
||||
### 获取听记基础信息
|
||||
```
|
||||
Usage:
|
||||
dws minutes get info [flags]
|
||||
Example:
|
||||
dws minutes get info --id <taskUuid>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
```
|
||||
|
||||
返回字段: 创建人、开始时间、截止时间、听记标题、听记访问链接URL
|
||||
|
||||
### 获取听记 AI 摘要
|
||||
```
|
||||
Usage:
|
||||
dws minutes get summary [flags]
|
||||
Example:
|
||||
dws minutes get summary --id <taskUuid>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
```
|
||||
|
||||
返回 Markdown 格式摘要,涵盖会议主题、核心结论、关键讨论点等
|
||||
|
||||
### 获取听记关键字列表
|
||||
```
|
||||
Usage:
|
||||
dws minutes get keywords [flags]
|
||||
Example:
|
||||
dws minutes get keywords --id <taskUuid>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
```
|
||||
|
||||
### 获取听记语音转写原文
|
||||
```
|
||||
Usage:
|
||||
dws minutes get transcription [flags]
|
||||
Example:
|
||||
dws minutes get transcription --id <taskUuid>
|
||||
dws minutes get transcription --id <taskUuid> --direction 1
|
||||
Flags:
|
||||
--direction string 排序方向: 0=正序, 1=倒序 (默认 0)
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
--next-token string 下一页的token 首次查询可空 后续查询需填写前次请求返回的nextToken
|
||||
```
|
||||
|
||||
每条记录包含: 发言人信息、转写文本、对应时间戳
|
||||
|
||||
### 获取听记中提取的待办事项
|
||||
```
|
||||
Usage:
|
||||
dws minutes get todos [flags]
|
||||
Example:
|
||||
dws minutes get todos --id <taskUuid>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
```
|
||||
|
||||
每条记录包含: 待办内容、待办唯一ID、参与人信息、待办时间
|
||||
|
||||
### 批量查询听记详情
|
||||
```
|
||||
Usage:
|
||||
dws minutes get batch [flags]
|
||||
Example:
|
||||
dws minutes get batch --ids uuid1,uuid2,uuid3
|
||||
Flags:
|
||||
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态
|
||||
|
||||
### 修改听记标题
|
||||
```
|
||||
Usage:
|
||||
dws minutes update title [flags]
|
||||
Example:
|
||||
dws minutes update title --id <taskUuid> --title "Q2 复盘会议"
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
|
||||
--title string 新标题 (必填)
|
||||
```
|
||||
|
||||
### 发起听记(开始录音)
|
||||
```
|
||||
Usage:
|
||||
dws minutes record start [flags]
|
||||
Example:
|
||||
dws minutes record start
|
||||
dws minutes record start --session-id <sessionId>
|
||||
Flags:
|
||||
--session-id string AI 助理会话 ID (可选)
|
||||
```
|
||||
|
||||
### 暂停听记录音
|
||||
```
|
||||
Usage:
|
||||
dws minutes record pause [flags]
|
||||
Example:
|
||||
dws minutes record pause --id <taskUuid>
|
||||
dws minutes record pause --id <taskUuid> --session-id <sessionId>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填)
|
||||
--session-id string AI 助理会话 ID (可选)
|
||||
```
|
||||
|
||||
### 恢复听记录音
|
||||
```
|
||||
Usage:
|
||||
dws minutes record resume [flags]
|
||||
Example:
|
||||
dws minutes record resume --id <taskUuid>
|
||||
dws minutes record resume --id <taskUuid> --session-id <sessionId>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填)
|
||||
--session-id string AI 助理会话 ID (可选)
|
||||
```
|
||||
|
||||
### 结束听记录音
|
||||
```
|
||||
Usage:
|
||||
dws minutes record stop [flags]
|
||||
Example:
|
||||
dws minutes record stop --id <taskUuid>
|
||||
dws minutes record stop --id <taskUuid> --session-id <sessionId>
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填)
|
||||
--session-id string AI 助理会话 ID (可选)
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选)
|
||||
用户说"别人给我的听记/共享听记" → `list shared`(可附加 `--query`、`--start`、`--end` 筛选)
|
||||
用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选)
|
||||
用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数
|
||||
用户说"听记详情/听记信息" → `get info`
|
||||
用户说"摘要/总结/会议纪要" → `get summary`
|
||||
用户说"关键字/关键词" → `get keywords`
|
||||
用户说"原文/转写/录音文字" → `get transcription`
|
||||
用户说"会议待办/听记待办" → `get todos`
|
||||
用户说"改听记标题/重命名听记" → `update title`
|
||||
用户说"发起听记/开始录音" → `record start`
|
||||
用户说"暂停听记/暂停录音" → `record pause`
|
||||
用户说"继续听记/恢复录音" → `record resume`
|
||||
用户说"结束听记/结束录音" → `record stop`
|
||||
用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 0. 发起听记(开始录音)
|
||||
dws minutes record start --format json
|
||||
|
||||
# 1. 查看我的听记列表 — 提取 taskUuid
|
||||
dws minutes list mine --format json
|
||||
dws minutes list mine --max 10 --next-token <nextToken> --format json
|
||||
dws minutes list mine --query "周会" --format json
|
||||
|
||||
# 1b. 查看共享给我的听记
|
||||
dws minutes list shared --max 20 --format json
|
||||
dws minutes list shared --query "日报" --format json
|
||||
|
||||
# 1c. 查看我有权限访问的所有听记(支持关键字和时间范围筛选)
|
||||
dws minutes list all --format json
|
||||
dws minutes list all --query "周会" --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00" --format json
|
||||
|
||||
# 2. 获取 AI 摘要
|
||||
dws minutes get summary --id <taskUuid> --format json
|
||||
|
||||
# 3. 查看完整转写原文
|
||||
dws minutes get transcription --id <taskUuid> --format json
|
||||
|
||||
# 4. 提取待办事项
|
||||
dws minutes get todos --id <taskUuid> --format json
|
||||
|
||||
# 5. 修改标题
|
||||
dws minutes update title --id <taskUuid> --title "新标题" --format json
|
||||
|
||||
# 6. 录音控制(基于 start 返回的 taskUuid)
|
||||
dws minutes record pause --id <taskUuid> --format json
|
||||
dws minutes record resume --id <taskUuid> --format json
|
||||
dws minutes record stop --id <taskUuid> --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list mine` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
|
||||
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
|
||||
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
|
||||
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
|
||||
|
||||
## 注意事项
|
||||
- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参
|
||||
- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid`
|
||||
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
|
||||
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
|
||||
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
|
||||
- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
|
||||
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
|
||||
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
|
||||
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序
|
||||
- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [minutes_recent_summary.py](../../scripts/minutes_recent_summary.py) | 获取最近听记的 AI 摘要并合并 | `python minutes_recent_summary.py --max 5` |
|
||||
| [minutes_extract_todos.py](../../scripts/minutes_extract_todos.py) | 从听记中提取待办事项汇总 | `python minutes_extract_todos.py --max 5` |
|
||||
Reference in New Issue
Block a user