Compare commits

...
4 Commits
Author SHA1 Message Date
修雨 b101d5b12b docs(skills,chat): clarify --title is required for chat message send (#174)
The backend MCP tools `send_direct_message_as_user` and
`send_message_as_user` both require `title` as a required field.
However, the skill documentation and --help Long text described
--title as "optional", causing AI models to omit it and trigger
a vague `business error: success=false`.

Update descriptions in:
- skills/references/products/chat.md
- internal/helpers/chat.go (command Long text)

Closes #173
2026-04-27 16:49:05 +08:00
xianfeng wangandgithub-actions[bot] acc5dc8a3c feat(plugins): register stdio plugin commands from CLI overlay before discovery (#179)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* plugin in help

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 16:20:35 +08:00
ybc❤️zyrand猷诺 e3b0c9b1ed feat: add mail product reference doc and register in SKILL.md (#167)
- Add skills/references/products/mail.md with 4 subcommands:
  mailbox list, message search (KQL), message get, message send
- Register mail in SKILL.md: product table, intent routing, description
- All params verified against dws schema and --mock execution

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-27 09:35:49 +08:00
修雨 cc1f0a47e3 docs(changelog): backfill v1.0.16 (and v1.0.14 docs-only re-tag) (#171)
* docs(changelog): backfill v1.0.16 (and v1.0.14 docs-only re-tag)

v1.0.16 ships discovery service abstraction + schema v3 (#156),
open-edition helper grafting (#169), chat message send destination
routing (#170), and device flow defensive credential reset (#157).
The v1.0.16 GitHub release notes are refreshed in lock-step with
this CHANGELOG entry (previously they were the auto-generated
4-line commit list).

v1.0.14 also gets a one-line entry noting it is a docs-only re-tag
of v1.0.13 (#153 backfilled v1.0.13 release notes after the binary
was already published; no functional change).

* docs(changelog): reorder v1.0.14 to date-descending position

v1.0.14 (2026-04-22) was sitting above v1.0.15 (2026-04-23). Move it between v1.0.15 and v1.0.13 so the file reads strictly date-descending.

* docs(changelog): trim v1.0.16 entry to match prior granularity

Drop nested sub-bullets (discovery internals / schema v3 enumeration / device-flow root cause) and the standalone Tests section; keep one bullet per PR. Aligns with v1.0.4 / v1.0.7 / v1.0.8 single-paragraph + 3-5 bullets style. 47 lines → 7 net (-40).
2026-04-27 09:35:11 +08:00
11 changed files with 993 additions and 101 deletions
+1 -1
View File
@@ -1 +1 @@
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.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">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+23
View File
@@ -4,6 +4,25 @@ 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.16] - 2026-04-24
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
### Added
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
### Changed
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
### Fixed
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
## [1.0.15] - 2026-04-23
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
@@ -46,6 +65,10 @@ Compat layer gains **subcommand merging** under shared parents so multiple serve
- `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
- `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
## [1.0.14] - 2026-04-22
Docs-only re-tag of v1.0.13. The single commit (#153) backfills the v1.0.13 release notes after the binary was already published; no functional or CLI surface change.
## [1.0.13] - 2026-04-22
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
+206
View File
@@ -0,0 +1,206 @@
// 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 app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
// from its manifest. It supports two forms:
//
// 1. inline JSON object in manifest.MCPServers[key].CLI
// 2. a relative file path (JSON string) pointing to an external overlay
// file anchored at the plugin root (e.g. "overlay.json")
//
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
serverID := sc.Key
overlay := market.CLIOverlay{
ID: serverID,
Command: serverID,
}
srv, ok := p.Manifest.MCPServers[sc.Key]
if !ok || len(srv.CLI) == 0 {
return overlay
}
cliData := srv.CLI
// A JSON string is interpreted as a relative path to an external
// overlay file (e.g. "overlay.json") anchored at the plugin root.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
return overlay
}
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
// Returns nil if tools is empty.
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
if len(tools) == 0 {
return nil
}
detailTools := make([]market.DetailTool, 0, len(tools))
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
return map[string][]market.DetailTool{overlayID: detailTools}
}
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
// server using only its manifest + overlay.json — no subprocess required.
//
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
// registration (legacy path).
//
// When a warm tools cache exists for this server, its DetailTools are passed
// to BuildDynamicCommands so flag types are enriched from the last successful
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
// only; the next startup after a successful refresh picks up the full schema.
func registerStdioServerFromOverlay(
p *plugin.Plugin,
sc plugin.StdioServerClient,
runner executor.Runner,
store *cache.Store,
) ([]*cobra.Command, market.ServerDescriptor, bool) {
overlay := resolveStdioOverlay(p, sc)
if len(overlay.ToolOverrides) == 0 {
return nil, market.ServerDescriptor{}, false
}
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
// Warm-cache enrichment: if a prior successful discovery wrote a
// non-empty tool list, use its schema to enrich flag types.
var detailsByID map[string][]market.DetailTool
if store != nil {
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
}
}
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides),
"commands", len(cmds),
"enriched", detailsByID != nil)
return cmds, descriptor, true
}
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
// subprocess and persists the result so the next startup can enrich
// overlay-registered commands with typed flags. It never constructs cobra
// commands; command registration has already happened synchronously from
// the overlay before this function runs.
//
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
// SaveTools entirely so a transient error cannot poison the warm cache
// with a null-tools snapshot.
func refreshStdioToolsCache(
p *plugin.Plugin,
sc plugin.StdioServerClient,
store *cache.Store,
timeouts pluginColdTimeouts,
) {
if store == nil {
return
}
tools := discoverStdioTools(p, sc, timeouts)
if len(tools) == 0 {
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
"plugin", p.Manifest.Name, "server", sc.Key)
return
}
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
}); err != nil {
slog.Warn("plugin: failed to persist stdio tools cache",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return
}
slog.Debug("plugin: stdio tools cache refreshed",
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
}
// hasOverlayToolOverrides reports whether a stdio plugin server carries
// enough CLI metadata to be registered via the overlay-first path. Used by
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
// buckets without doing the overlay parse twice.
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
}
+398
View File
@@ -0,0 +1,398 @@
// 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 app
import (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// withCleanStdioRegistry snapshots and restores the package-level stdio
// client registry so tests that call RegisterStdioClient don't leak state
// across cases.
func withCleanStdioRegistry(t *testing.T) {
t.Helper()
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
}
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
// CLIOverlay with the given tool-override map. The stdio client is created
// but never started, since the overlay-first path does not require the
// subprocess to be running for command registration.
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
t.Helper()
raw, err := json.Marshal(overlay)
if err != nil {
t.Fatalf("marshal overlay: %v", err)
}
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: pluginName,
Version: "1.0.0",
Description: pluginName + " plugin",
MCPServers: map[string]*plugin.MCPServer{
serverKey: {
Type: "stdio",
Command: "/usr/bin/true", // never executed by overlay-first path
CLI: raw,
},
},
},
Root: t.TempDir(),
}
sc := plugin.StdioServerClient{
Key: serverKey,
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
}
return p, sc
}
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
// the core promise of the overlay-first path: when overlay.json ships
// ToolOverrides, commands appear immediately — no subprocess probe.
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
Groups: map[string]market.CLIGroupDef{
"meeting": {Description: "会议控制"},
"member": {Description: "成员管理"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
store := cache.NewStore(t.TempDir())
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
}
if len(cmds) == 0 {
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
}
var root *struct{ name, path string }
_ = root
found := false
for _, c := range cmds {
if c.Name() == "conference-local" {
found = true
// Groups must be attached as sub-commands.
groups := map[string]bool{}
for _, sub := range c.Commands() {
groups[sub.Name()] = true
}
if !groups["meeting"] {
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
}
if !groups["member"] {
t.Errorf("missing 'member' group sub-command, children = %v", groups)
}
}
}
if !found {
names := []string{}
for _, c := range cmds {
names = append(names, c.Name())
}
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
}
// AppendDynamicServer registration: product ID should land in
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
// visible even under a restrictive VisibleProducts hook.
if !DirectRuntimeProductIDs()["conference-local"] {
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
}
// RegisterStdioClient side-effect: the runtime must be able to look up
// the StdioClient when the endpoint is invoked later.
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
t.Error("LookupStdioClient missing conference-local/conference-local")
}
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
}
}
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
// tools cache with a schema-bearing DetailTool and asserts the resulting
// leaf command picks up the typed flag derived from InputSchema.
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "cache-plugin",
Command: "cache-plugin",
ToolOverrides: map[string]market.CLIToolOverride{
"echo": {CLIName: "echo", Description: "Echo input"},
},
}
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "echo",
Description: "Echo the input",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"message": map[string]any{"type": "string"},
},
"required": []any{"message"},
},
},
},
}); err != nil {
t.Fatalf("SaveTools: %v", err)
}
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok || len(cmds) == 0 {
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
}
var echoLeaf *leafMatch
for _, top := range cmds {
if top.Name() != "cache-plugin" {
continue
}
for _, sub := range top.Commands() {
if sub.Name() == "echo" {
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
}
}
}
if echoLeaf == nil {
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
}
if !echoLeaf.hasFlag {
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
}
}
type leafMatch struct {
name string
hasFlag bool
}
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
// asserts the fallback contract: when overlay.json is missing toolOverrides,
// the overlay-first path declines so the caller can route the entry through
// the legacy discovery-first registerStdioServer.
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
// on runtime discovery for its tool list).
overlay := market.CLIOverlay{
ID: "legacy-plugin",
Command: "legacy-plugin",
}
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if ok {
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
}
if cmds != nil {
t.Errorf("cmds = %v, want nil", cmds)
}
if DirectRuntimeProductIDs()["legacy-plugin"] {
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
}
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
t.Error("stdio client must NOT be registered in fallback case")
}
}
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
// timeout, empty tool list), the existing warm cache must remain intact so
// the next startup still enriches flags from the last good snapshot.
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
ID: "refresh-plugin",
Command: "refresh-plugin",
})
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
goodSnapshot := cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "ping",
Description: "Health check",
InputSchema: map[string]any{"type": "object"},
},
},
}
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
t.Fatalf("seed SaveTools: %v", err)
}
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
// a no-op and must NOT overwrite the good cache with a null snapshot.
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
if err != nil {
t.Fatalf("LoadTools after failed refresh: %v", err)
}
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
}
}
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
// test for the loadPlugins split decision: stdio plugins whose overlay ships
// ToolOverrides must have their commands visible on the root immediately,
// WITHOUT waiting on any discovery handshake. It drives the same sequence
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
// visibility filter even when no discovery has run.
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Simulate a wukong-like edition that declares a static VisibleProducts
// whitelist NOT containing our plugin. This is the exact scenario where
// the original bug surfaced.
overrideVisibleProducts(t, []string{"calendar", "doc"})
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
// No discovery runs — no cache seeded. This mirrors a cold-start where
// the subprocess is unavailable (or just slow) yet the user expects
// `dws --help` to still list the plugin.
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false")
}
root := &cobra.Command{Use: "dws"}
// Also add a sibling command that is NOT a registered product so we can
// prove the visibility filter still hides non-product commands.
bogus := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(bogus)
for _, c := range cmds {
root.AddCommand(c)
}
hideNonDirectRuntimeCommands(root)
var pluginCmd *cobra.Command
for _, c := range root.Commands() {
if c.Name() == "conference-local" {
pluginCmd = c
}
}
if pluginCmd == nil {
t.Fatal("conference-local missing from root after overlay-first registration")
}
if pluginCmd.Hidden {
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
}
if !bogus.Hidden {
t.Error("bogus-not-a-product must be hidden by the visibility filter")
}
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
}
}
// TestHasOverlayToolOverrides exercises the split-decision helper used by
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
func TestHasOverlayToolOverrides(t *testing.T) {
cases := []struct {
name string
overlay market.CLIOverlay
want bool
}{
{
name: "empty overlay",
overlay: market.CLIOverlay{ID: "x", Command: "x"},
want: false,
},
{
name: "overlay with overrides",
overlay: market.CLIOverlay{
ID: "x",
Command: "x",
ToolOverrides: map[string]market.CLIToolOverride{
"foo": {CLIName: "foo"},
},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
got := hasOverlayToolOverrides(p, sc)
if got != tc.want {
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
}
})
}
}
+52 -77
View File
@@ -655,16 +655,7 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
var allowedProducts map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowedProducts = make(map[string]bool, len(products))
for _, p := range products {
allowedProducts[p] = true
}
} else {
allowedProducts = DirectRuntimeProductIDs()
}
allowedProducts := resolveVisibleProducts()
staticCommands := map[string]bool{
"auth": true,
"cache": true,
@@ -1162,11 +1153,31 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
sharedStore := cacheStoreFromEnv()
coldTimeouts := resolvePluginColdTimeouts()
// Fan out HTTP and stdio discovery in parallel. Each goroutine resolves
// its cache hit locally (no network) or runs a bounded cold-path probe.
// Wall-clock cost ≈ max(individual plugin latencies), not the sum.
// Phase A: stdio overlay-first registration (synchronous, no I/O).
// Plugins whose overlay.json declares ToolOverrides register their full
// command tree up-front from manifest metadata alone — no subprocess
// handshake required. This fixes the "discovery fails → no commands
// ever appear" lock-out and keeps `dws --help` reliable even when the
// underlying MCP server is temporarily unavailable.
var legacyStdioEntries []stdioEntry
for _, e := range stdioEntries {
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
if !ok {
legacyStdioEntries = append(legacyStdioEntries, e)
continue
}
pluginCmds = append(pluginCmds, cmds...)
}
// Phase B: fan out discovery in parallel.
// - HTTP plugins: same behaviour as before (discovery-first).
// - stdio overlay-first plugins: async cache refresh only; their
// commands are already registered. Failures are non-fatal and do
// NOT poison the warm-cache with a null-tools snapshot.
// - stdio legacy plugins (overlay without toolOverrides): preserve
// the old discovery-first path for backwards compatibility.
httpResults := make([][]*cobra.Command, len(httpServers))
stdioResults := make([][]*cobra.Command, len(stdioEntries))
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
var wg sync.WaitGroup
for i, ps := range httpServers {
wg.Add(1)
@@ -1175,18 +1186,30 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
}(i, ps)
}
for i, e := range stdioEntries {
// overlay-first stdio: async refresh (no command building here).
for _, e := range stdioEntries {
if !hasOverlayToolOverrides(e.plugin, e.sc) {
continue
}
wg.Add(1)
go func(e stdioEntry) {
defer wg.Done()
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
}(e)
}
// legacy stdio: discovery-first (commands depend on tool list).
for i, e := range legacyStdioEntries {
wg.Add(1)
go func(idx int, e stdioEntry) {
defer wg.Done()
stdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
}(i, e)
}
wg.Wait()
for _, cmds := range httpResults {
pluginCmds = append(pluginCmds, cmds...)
}
for _, cmds := range stdioResults {
for _, cmds := range legacyStdioResults {
pluginCmds = append(pluginCmds, cmds...)
}
@@ -1472,6 +1495,11 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
// buildStdioCommands constructs Cobra commands from a tool list and
// registers the runtime dispatch state (StdioClient + dynamic server).
// Returns nil for an empty tool list.
//
// This is the legacy discovery-first path, used only for stdio plugins whose
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
// register commands up-front via registerStdioServerFromOverlay, bypassing
// this function entirely (see plugin_stdio_overlay.go).
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
slog.Debug("plugin: stdio server has no tools",
@@ -1479,44 +1507,14 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
return nil
}
// Build CLIOverlay: use manifest CLI metadata if present, else auto-generate.
serverID := sc.Key
overlay := market.CLIOverlay{
ID: serverID,
Command: serverID,
}
if srv, ok := p.Manifest.MCPServers[sc.Key]; ok && len(srv.CLI) > 0 {
cliData := srv.CLI
// If cli is a JSON string, treat it as a relative file path to an overlay file.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
}
overlay := resolveStdioOverlay(p, sc)
// Auto-generate ToolOverrides from discovered tools when not provided.
// Auto-generate ToolOverrides from discovered tools when not provided
// by the manifest/overlay (legacy discovery-first path).
if len(overlay.ToolOverrides) == 0 {
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
if len(overlay.Prefixes) == 0 {
overlay.Prefixes = []string{serverID}
overlay.Prefixes = []string{overlay.ID}
}
for _, tool := range tools {
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
@@ -1525,43 +1523,20 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
}
}
// Construct virtual endpoint and server descriptor.
endpoint := StdioEndpoint(p.Manifest.Name, sc.Key)
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: endpoint,
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
AppendDynamicServer(descriptor)
// Register with pluginName/serverKey format for cleanup by plugin name
RegisterStdioClient(p.Manifest.Name+"/"+serverID, sc.Client)
// Convert tool descriptors to DetailTool entries for flag generation.
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
detailsByID[serverID] = detailTools
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
+20 -17
View File
@@ -84,21 +84,30 @@ func renderRootHelp(root *cobra.Command) {
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
}
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
for _, p := range fn() {
allowed[p] = true
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
return allowed
}
func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
if root == nil {
return nil
}
var allowed map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowed = make(map[string]bool, len(products))
for _, p := range products {
allowed[p] = true
}
} else {
allowed = DirectRuntimeProductIDs()
}
allowed := resolveVisibleProducts()
if len(allowed) == 0 {
return nil
}
@@ -121,13 +130,7 @@ func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
return nil
}
productCommands := DirectRuntimeProductIDs()
if fn := edition.Get().VisibleProducts; fn != nil {
productCommands = make(map[string]bool, len(fn()))
for _, product := range fn() {
productCommands[product] = true
}
}
productCommands := resolveVisibleProducts()
commands := make([]*cobra.Command, 0)
for _, cmd := range root.Commands() {
+125
View File
@@ -0,0 +1,125 @@
// 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 app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// overrideVisibleProducts temporarily installs an edition hook exposing the
// given static product list and restores the previous hooks on cleanup.
func overrideVisibleProducts(t *testing.T, products []string) {
t.Helper()
prev := edition.Get()
edition.Override(&edition.Hooks{
VisibleProducts: func() []string { return products },
})
t.Cleanup(func() { edition.Override(prev) })
}
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
// the product ID ends up in DirectRuntimeProductIDs() without triggering
// network discovery.
func registerPluginProduct(t *testing.T, id, endpoint string) {
t.Helper()
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
// is a regression for the dws-wukong plugin-visibility bug: when an edition
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
// IDs) and a plugin registers a new product via AppendDynamicServer
// (e.g. `conference-local`), the plugin command must stay visible because the
// dynamic registry takes precedence over the hook's static whitelist.
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
hideNonDirectRuntimeCommands(root)
if calendarCmd.Hidden {
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
}
if pluginCmd.Hidden {
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
}
if !bogusCmd.Hidden {
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
}
}
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
// renderer surfaces plugin products in the "Discovered MCP Services" section
// and does not misclassify them as utility commands.
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
authCmd := &cobra.Command{Use: "auth"}
root.AddCommand(calendarCmd, pluginCmd, authCmd)
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
}
if !containsCommand(services, "calendar") {
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
}
utilities := visibleUtilityRootCommands(root)
if containsCommand(utilities, "conference-local") {
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
}
if !containsCommand(utilities, "auth") {
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
}
}
func containsCommand(cmds []*cobra.Command, name string) bool {
for _, c := range cmds {
if c.Name() == name {
return true
}
}
return false
}
func commandNames(cmds []*cobra.Command) []string {
names := make([]string, 0, len(cmds))
for _, c := range cmds {
names = append(names, c.Name())
}
return names
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。可选 --title 作为消息标题。`,
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。`,
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> --title "提醒" --text "请确认"
+3 -1
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
cli_version: ">=1.0.15"
---
@@ -36,6 +36,7 @@ cli_version: ">=1.0.15"
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) |
| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
## 意图判断决策树
@@ -50,6 +51,7 @@ cli_version: ">=1.0.15"
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
+4 -4
View File
@@ -181,7 +181,7 @@ Flags:
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。必须提供 --title 作为消息标题。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
@@ -203,7 +203,7 @@ Flags:
--group string 群聊 openconversation_id(群聊时必填)
--user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
--title string 消息标题(可选,默认「消息」)
--title string 消息标题(必填)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
@@ -601,10 +601,10 @@ dws chat message list-unread-conversations --count 20 --format json
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
# 4. 以个人身份单聊(通过 userId)
dws chat message send --user <userId> "你好" --format json
dws chat message send --user <userId> --title "问候" "你好" --format json
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --title "问候" "你好" --format json
# 5. 机器人发群消息(Markdown)
dws chat message send-by-bot --robot-code <robot-code> \
+160
View File
@@ -0,0 +1,160 @@
# 邮箱 (mail) 命令参考
## 命令总览
### 查询可用邮箱地址
```
Usage:
dws mail mailbox list [flags]
Example:
dws mail mailbox list
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
### 搜索邮件 (KQL 语法)
```
Usage:
dws mail message search [flags]
Example:
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
Flags:
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
--email string 搜索目标邮箱地址 (必填)
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
--size string 每页返回数量(最大限制 100, 默认 20) (必填),别名: --limit, --page-size
```
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
### KQL 查询字段说明
| 字段 | 类型 | 说明 | 正确示例 | 错误示例 |
|------|------|------|----------|----------|
| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) |
| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) |
| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` |
| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) |
| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) |
| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` |
| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) |
| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) |
| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) |
| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:alice@company.com`、`from:"张 三"`、`from:"alice<a@b.com>"` | `from:张 三`(含空格未加引号) |
| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:bob@company.com`、`to:"李 四"`、`to:"alice<a@b.com>"` | `to:李 四`(含空格未加引号) |
**组合查询说明:**
- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写)
- 括号用于分组:`(from:alice OR from:bob) AND folderId:2`
- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)`
### message search 返回值说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) |
| `total` | `int32` | 符合条件的总邮件数 |
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 |
**翻页示例:**
```bash
# 第一页
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
```
### 查看邮件完整内容
```
Usage:
dws mail message get [flags]
Example:
dws mail message get --email user@company.com --id <messageId>
Flags:
--email string 邮件所属邮箱地址 (必填)
--id string 邮件 ID (必填)
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
### 发送邮件
```
Usage:
dws mail message send [flags]
Example:
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成任务A和任务B"
Flags:
--body string 邮件正文,支持 Markdown 格式 (必填)
--cc string 抄送人列表
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表 (必填)
```
## 通用错误说明
以下错误适用于所有 mail 命令。
| 错误标识 | 含义 | 处理建议 |
|----------|------|----------|
| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 |
## 意图判断
用户说"我的邮箱/邮箱地址" → `mailbox list`
用户说"找邮件/搜邮件/查邮件" → `message search`
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
用户说"给(某人名字)发邮件" → 先 `aisearch person` 获取 userId,再 `contact user get` 获取收件人邮箱,再 `message send`
## 严格禁止 (NEVER DO)
- 明确禁止猜测、假设、推断发件人和收件人邮箱
- 无法获取邮箱时,强引导ask_human,由用户确认,不要通过假设或其他方式继续执行
## 核心工作流
```bash
# 1. 查看可用邮箱 — 提取邮箱地址
dws mail mailbox list --format json
# 2. 搜索邮件 — 提取 messageId
dws mail message search --email user@company.com \
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
# 3. 查看邮件详情
dws mail message get --email user@company.com --id <messageId> --format json
# 4. 发送邮件
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成…" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `mailbox list` | 邮箱地址 | message search/get/send 的 --email/--from |
| `message search` | `messageId` | message get 的 --id |
| `aisearch person` → `contact user get` | 用户邮箱 (orgAuthEmail) | message send 的 --to/--cc (跨产品) |
## 注意事项
- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。选择邮箱时优先匹配用户当前所在企业的企业邮箱;若有多个可选,向用户确认后再操作
- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容
- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号
- `--cc` 抄送人支持多人,逗号分隔
- 收件人邮箱获取:用户只知道同事名字时,先通过 `dws aisearch person --keyword "名字" --dimension name` 获取 userId,再 `dws contact user get --ids <userId>` 从返回中提取 orgAuthEmail 字段