Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d41214988a | ||
|
|
5783c4e82a | ||
|
|
baafd6fe7d | ||
|
|
23c3b74979 |
+18
-1
@@ -6,10 +6,27 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
|
||||
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
|
||||
|
||||
## [1.0.55-beta.5] - 2026-07-28
|
||||
|
||||
This beta validates expanded personal event consumption, complete Agent-visible
|
||||
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
|
||||
observability, and hardened document, Drive, approval, and Todo command
|
||||
contracts on top of the `v1.0.55-beta.4` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
|
||||
- **Shortcut Runtime Schema delivery** — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
|
||||
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
|
||||
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
|
||||
|
||||
## [1.0.55-beta.4] - 2026-07-27
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.55-beta.4"
|
||||
version "1.0.55-beta.5"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "05b269fe44a125ee8b368d6228c5229950b8216872fb569741d1e30a83ce952a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b0d7604299336c83b7805d3b1a47a90668f2e2f3fc54702b0e846bb2907b6170"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "a9c1dd5c6171091a84fc18e5081c9f75d037826cd3966726545d715ce832ae31"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "5e97ba398f5a3e15b9d235bc53f596d31a4d6b7af7bb2185b7b48beeda6a2ebb"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-skills.zip"
|
||||
sha256 "4ebc0294b65d90adb5c5d639a548b528af240e4117ccca3d388e2efb6030170a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
|
||||
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -730,15 +730,24 @@ var MessagesResourceURL = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
|
||||
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId"},
|
||||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
|
||||
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
},
|
||||
// message-id is required, but accept the natural aliases agents reach for
|
||||
// (the message-list output field is openMessageId/msgId). Declared via a
|
||||
// constraint rather than Required because a shortcut's Required check only
|
||||
// looks at the primary flag name, so a hidden alias could not satisfy it.
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"message-id", "msg-id", "open-message-id"}},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-resource-url --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("get_resource_download_url", map[string]any{
|
||||
"resourceType": rt.Str("type"),
|
||||
"resourceId": rt.Str("resource-id"),
|
||||
"openMessageId": rt.Str("message-id"),
|
||||
"openMessageId": rt.StrFirst("message-id", "msg-id", "open-message-id"),
|
||||
"openConversationId": rt.Str("open-conversation-id"),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -103,6 +103,36 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
wantTool: "query_msg_read_status",
|
||||
wantArgs: map[string]any{"openConversationId": "cid-1"},
|
||||
},
|
||||
{
|
||||
name: "message resource url msg id alias",
|
||||
argv: []string{
|
||||
"chat", "+messages-resource-url", "--resource-id", "resource-1",
|
||||
"--msg-id", "msg-1", "--open-conversation-id", "cid-1",
|
||||
},
|
||||
wantProduct: "im",
|
||||
wantTool: "get_resource_download_url",
|
||||
wantArgs: map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": "resource-1",
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message resource url open message id alias",
|
||||
argv: []string{
|
||||
"chat", "+messages-resource-url", "--resource-id", "resource-1",
|
||||
"--open-message-id", "msg-1", "--open-conversation-id", "cid-1",
|
||||
},
|
||||
wantProduct: "im",
|
||||
wantTool: "get_resource_download_url",
|
||||
wantArgs: map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": "resource-1",
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -67,7 +67,7 @@ var Broadcast = shortcut.Shortcut{
|
||||
|
||||
// Step 1 — resolve this name to a unique userId. On failure
|
||||
// (unknown / ambiguous) record it and keep going.
|
||||
user, err := resolveUser(rt, name)
|
||||
user, err := resolveOpenDingTalkUser(rt, name)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error()))
|
||||
continue
|
||||
|
||||
@@ -16,6 +16,7 @@ package smart
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
@@ -31,7 +32,8 @@ type platformCoverageCall struct {
|
||||
}
|
||||
|
||||
type platformCoverageCaller struct {
|
||||
calls []platformCoverageCall
|
||||
calls []platformCoverageCall
|
||||
contactSearchResult string
|
||||
}
|
||||
|
||||
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -39,7 +41,10 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
text := `{"result":[]}`
|
||||
switch product + "/" + tool {
|
||||
case "contact/search_contact_by_key_word":
|
||||
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
|
||||
text = f.contactSearchResult
|
||||
if text == "" {
|
||||
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
|
||||
}
|
||||
case "contact/get_current_user_profile":
|
||||
text = `{"result":{"userId":"u1"}}`
|
||||
case "im/search_groups":
|
||||
@@ -48,6 +53,27 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExternalContactAmbiguity(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[
|
||||
{"userId":"u1","name":"张三","openDingTalkId":"open1"},
|
||||
{"openDingtalkId":"open-external","nick":"外部张三"}
|
||||
]}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+dm", "--to", "张三", "--text", "你好", "--yes"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous internal and external contacts unexpectedly resolved")
|
||||
}
|
||||
for _, want := range []string{"张三(u1)", "外部张三(open-external)"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("ambiguity error %q does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *platformCoverageCaller) Format() string { return "json" }
|
||||
func (f *platformCoverageCaller) DryRun() bool { return false }
|
||||
func (f *platformCoverageCaller) Fields() string { return "" }
|
||||
|
||||
@@ -49,7 +49,7 @@ var DM = shortcut.Shortcut{
|
||||
text := rt.Str("text")
|
||||
|
||||
// Step 1 — resolve the recipient name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("to"))
|
||||
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,6 +35,17 @@ type contactUser struct {
|
||||
// - errors with a clear message if nobody matches;
|
||||
// - errors listing the candidates if the name is ambiguous (never guesses).
|
||||
func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||
return resolveUserByName(rt, name, false)
|
||||
}
|
||||
|
||||
// resolveOpenDingTalkUser also accepts external / cross-org contacts that have
|
||||
// an openDingTalkId but no organization-scoped userId. Use it only for flows
|
||||
// whose downstream interface consumes openDingTalkId.
|
||||
func resolveOpenDingTalkUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||
return resolveUserByName(rt, name, true)
|
||||
}
|
||||
|
||||
func resolveUserByName(rt *shortcut.RuntimeContext, name string, includeOpenIDOnly bool) (contactUser, error) {
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": name,
|
||||
})
|
||||
@@ -42,18 +53,31 @@ func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error)
|
||||
return contactUser{}, err
|
||||
}
|
||||
users := extractUsers(data)
|
||||
if !includeOpenIDOnly {
|
||||
users = usersWithUserID(users)
|
||||
}
|
||||
switch {
|
||||
case len(users) == 0:
|
||||
return contactUser{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("通讯录里没找到叫 %q 的人;换个更完整的姓名再试。", name))
|
||||
case len(users) > 1:
|
||||
return contactUser{}, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或直接用对应命令传 userId。",
|
||||
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或改用对应命令直接传用户 ID。",
|
||||
name, len(users), strings.Join(userLabels(users), "、")))
|
||||
}
|
||||
return users[0], nil
|
||||
}
|
||||
|
||||
func usersWithUserID(users []contactUser) []contactUser {
|
||||
out := make([]contactUser, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.userID != "" {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractUsers pulls {userId, openDingTalkId, name} out of a
|
||||
// search_contact_by_key_word response ({"result": [ {userId, name, ...} ]}).
|
||||
func extractUsers(data map[string]any) []contactUser {
|
||||
@@ -68,14 +92,27 @@ func extractUsers(data map[string]any) []contactUser {
|
||||
continue
|
||||
}
|
||||
id, _ := m["userId"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
nm, _ := m["name"].(string)
|
||||
openID, _ := m["openDingTalkId"].(string)
|
||||
if openID == "" {
|
||||
openID, _ = m["openDingtalkId"].(string)
|
||||
}
|
||||
// External / cross-org contacts come back with an empty userId and only
|
||||
// an openDingTalkId (verified live). Dropping them here made name→ID
|
||||
// resolution miss those people, or collapse to the wrong single match
|
||||
// when an in-org namesake also existed. Keep any row with at least one
|
||||
// usable identity; downstream (e.g. +dm) can act on the openDingTalkId.
|
||||
if id == "" && openID == "" {
|
||||
continue
|
||||
}
|
||||
nm, _ := m["name"].(string)
|
||||
if nm == "" {
|
||||
for _, k := range []string{"nick", "showName", "flowerName", "staffName", "userName"} {
|
||||
if v, _ := m[k].(string); v != "" {
|
||||
nm = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, contactUser{userID: id, openDingTalkID: openID, name: nm})
|
||||
}
|
||||
return out
|
||||
@@ -84,7 +121,11 @@ func extractUsers(data map[string]any) []contactUser {
|
||||
func userLabels(users []contactUser) []string {
|
||||
out := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, fmt.Sprintf("%s(%s)", u.name, u.userID))
|
||||
id := u.userID
|
||||
if id == "" {
|
||||
id = u.openDingTalkID
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s(%s)", u.name, id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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 smart
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractUsers(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": []any{
|
||||
// in-org contact: full identity
|
||||
map[string]any{"userId": "024083", "name": "朱鸿", "openDingTalkId": "DHUGO"},
|
||||
// external / cross-org contact: no userId, only openDingTalkId +
|
||||
// display name under a non-"name" key — must be kept, not dropped.
|
||||
map[string]any{"openDingtalkId": "DEXT", "nick": "外部小王"},
|
||||
// unusable row (no identity at all) is skipped
|
||||
map[string]any{"name": "无ID的人"},
|
||||
// non-map entry is skipped
|
||||
"garbage",
|
||||
},
|
||||
}
|
||||
users := extractUsers(data)
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("extractUsers kept %d, want 2: %#v", len(users), users)
|
||||
}
|
||||
if users[0].userID != "024083" || users[0].openDingTalkID != "DHUGO" || users[0].name != "朱鸿" {
|
||||
t.Errorf("in-org user = %#v", users[0])
|
||||
}
|
||||
// external contact kept via openDingTalkId, name resolved from "nick"
|
||||
if users[1].userID != "" || users[1].openDingTalkID != "DEXT" || users[1].name != "外部小王" {
|
||||
t.Errorf("external user = %#v", users[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsersNoResult(t *testing.T) {
|
||||
if got := extractUsers(map[string]any{}); got != nil {
|
||||
t.Errorf("no result should be nil, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersWithUserIDExcludesOpenIDOnlyContacts(t *testing.T) {
|
||||
users := []contactUser{
|
||||
{userID: "user-1", openDingTalkID: "open-1", name: "内部用户"},
|
||||
{openDingTalkID: "open-external", name: "外部联系人"},
|
||||
}
|
||||
got := usersWithUserID(users)
|
||||
if len(got) != 1 || got[0].userID != "user-1" {
|
||||
t.Fatalf("usersWithUserID() = %#v, want only the organization user", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLabelsFallsBackToOpenDingTalkID(t *testing.T) {
|
||||
got := userLabels([]contactUser{{openDingTalkID: "open-external", name: "外部联系人"}})
|
||||
if len(got) != 1 || got[0] != "外部联系人(open-external)" {
|
||||
t.Fatalf("userLabels() = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ var ShareDoc = shortcut.Shortcut{
|
||||
note := rt.Str("note")
|
||||
|
||||
// Step 1 — resolve the recipient name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("to"))
|
||||
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user