Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3210232876 | ||
|
|
162a2eb0a7 | ||
|
|
1a11c687ed | ||
|
|
dfed4ba37d | ||
|
|
b1f4a5d62a | ||
|
|
bf33ab622f |
@@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.55"
|
||||
version "1.0.56"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
|
||||
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
|
||||
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
|
||||
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
|
||||
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -3434,6 +3434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
conversationID := mustGetFlag(cmd, "open-conversation-id")
|
||||
messageID := mustGetFlag(cmd, "message-id")
|
||||
outputPath := mustGetFlag(cmd, "output")
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
switch resourceType {
|
||||
case "mediaId":
|
||||
@@ -3455,7 +3456,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: 获取下载 URL
|
||||
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "im", "get_resource_download_url", map[string]any{
|
||||
"resourceType": resourceType,
|
||||
"resourceId": resourceID,
|
||||
@@ -3488,11 +3491,20 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
}
|
||||
|
||||
// Step 2: HTTP GET 下载文件
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
|
||||
}
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSONUnescaped(map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": resourceURL,
|
||||
"output": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatDownloadMediaJSONPreservesLegacyResult(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
previousHTTPGetFile := httpGetFile
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
httpGetFile = previousHTTPGetFile
|
||||
})
|
||||
|
||||
const downloadURL = "https://download.example.test/photo.jpg?token=one&part=two"
|
||||
caller := &scriptedToolCaller{
|
||||
format: "json",
|
||||
steps: []scriptedToolStep{{
|
||||
text: `{"resourceUrl":"` + downloadURL + `"}`,
|
||||
}},
|
||||
}
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out = &Formatter{w: &stdout, errW: io.Discard}
|
||||
|
||||
outputDir := t.TempDir()
|
||||
wantOutput := filepath.Join(outputDir, "photo.jpg")
|
||||
httpGetFile = func(_ context.Context, gotURL string, _ map[string]string, gotOutput string) error {
|
||||
if gotURL != downloadURL {
|
||||
t.Fatalf("download URL = %q, want %q", gotURL, downloadURL)
|
||||
}
|
||||
if gotOutput != wantOutput {
|
||||
t.Fatalf("download output = %q, want %q", gotOutput, wantOutput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"message", "download-media",
|
||||
"--type=mediaId",
|
||||
"--resource-id=resource",
|
||||
"--message-id=message",
|
||||
"--open-conversation-id=conversation",
|
||||
"--output=" + outputDir,
|
||||
})
|
||||
if err := root.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Success bool `json:"success"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !got.Success || got.DownloadURL != downloadURL || got.Output != wantOutput {
|
||||
t.Fatalf("result = %#v, want success=true downloadUrl=%q output=%q", got, downloadURL, wantOutput)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "[INFO]") {
|
||||
t.Fatalf("JSON stdout contains progress text: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "?token=one&part=two") {
|
||||
t.Fatalf("downloadUrl was escaped or changed: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,193 @@ func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockMCPSmoke_ChatDownloadMediaJSONCLI(t *testing.T) {
|
||||
type mediaRequest struct {
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
header string
|
||||
}
|
||||
|
||||
mediaPayload := []byte("synthetic media payload")
|
||||
var mediaRequestsMu sync.Mutex
|
||||
var mediaRequests []mediaRequest
|
||||
mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mediaRequestsMu.Lock()
|
||||
mediaRequests = append(mediaRequests, mediaRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.Path,
|
||||
query: r.URL.RawQuery,
|
||||
header: r.Header.Get("X-Download-Fixture"),
|
||||
})
|
||||
mediaRequestsMu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(mediaPayload)
|
||||
}))
|
||||
defer mediaServer.Close()
|
||||
|
||||
downloadURL := mediaServer.URL + "/photo.jpg?fixture=one&part=two"
|
||||
downloadInfo, err := json.Marshal(map[string]any{
|
||||
"resourceUrl": downloadURL,
|
||||
"headers": map[string]string{
|
||||
"X-Download-Fixture": "synthetic",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requestsMu sync.Mutex
|
||||
var requests []recordedToolCall
|
||||
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := recordedToolCall{
|
||||
path: r.URL.Path,
|
||||
method: r.Method,
|
||||
authorization: r.Header.Get("Authorization"),
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
|
||||
call.err = err
|
||||
} else {
|
||||
call.jsonrpc = envelope.JSONRPC
|
||||
call.tool = envelope.Params.Name
|
||||
call.arguments = envelope.Params.Arguments
|
||||
if envelope.Method != "tools/call" {
|
||||
call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method)
|
||||
}
|
||||
}
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, call)
|
||||
requestsMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": envelope.ID,
|
||||
"result": map[string]any{
|
||||
"content": []map[string]any{{
|
||||
"type": "text",
|
||||
"text": string(downloadInfo),
|
||||
}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer mcpServer.Close()
|
||||
|
||||
outputDir := t.TempDir()
|
||||
env := isolatedCLIEnv(t, map[string]string{
|
||||
"DINGTALK_IM_MCP_URL": mcpServer.URL + "/mcp/im",
|
||||
})
|
||||
args := []string{
|
||||
"--token", "ci-smoke-token",
|
||||
"--format", "json",
|
||||
"chat", "message", "download-media",
|
||||
"--type", "mediaId",
|
||||
"--resource-id", "resource-001",
|
||||
"--message-id", "message-001",
|
||||
"--open-conversation-id", "conversation-001",
|
||||
"--output", outputDir,
|
||||
}
|
||||
stdout, stderr, err := runCLI(t, env, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws chat message download-media failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
recorded := append([]recordedToolCall(nil), requests...)
|
||||
requestsMu.Unlock()
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded)
|
||||
}
|
||||
call := recorded[0]
|
||||
if call.err != nil {
|
||||
t.Fatal(call.err)
|
||||
}
|
||||
if call.path != "/mcp/im" || call.method != http.MethodPost || call.jsonrpc != "2.0" {
|
||||
t.Fatalf("MCP request = path %q method %q jsonrpc %q", call.path, call.method, call.jsonrpc)
|
||||
}
|
||||
if call.authorization != "Bearer ci-smoke-token" {
|
||||
t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization)
|
||||
}
|
||||
if call.tool != "get_resource_download_url" {
|
||||
t.Fatalf("tool = %q, want get_resource_download_url", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": "resource-001",
|
||||
"openMessageId": "message-001",
|
||||
"openConversationId": "conversation-001",
|
||||
}
|
||||
if !reflect.DeepEqual(call.arguments, wantArgs) {
|
||||
t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs)
|
||||
}
|
||||
|
||||
mediaRequestsMu.Lock()
|
||||
recordedMedia := append([]mediaRequest(nil), mediaRequests...)
|
||||
mediaRequestsMu.Unlock()
|
||||
if len(recordedMedia) != 1 {
|
||||
t.Fatalf("media server received %d requests, want exactly one: %#v", len(recordedMedia), recordedMedia)
|
||||
}
|
||||
gotMedia := recordedMedia[0]
|
||||
if gotMedia.method != http.MethodGet || gotMedia.path != "/photo.jpg" {
|
||||
t.Fatalf("media request = method %q path %q", gotMedia.method, gotMedia.path)
|
||||
}
|
||||
if gotMedia.query != "fixture=one&part=two" {
|
||||
t.Fatalf("media query = %q, want fixture=one&part=two", gotMedia.query)
|
||||
}
|
||||
if gotMedia.header != "synthetic" {
|
||||
t.Fatalf("media header = %q, want synthetic", gotMedia.header)
|
||||
}
|
||||
|
||||
wantOutput := filepath.Join(outputDir, "photo.jpg")
|
||||
gotPayload, err := os.ReadFile(wantOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotPayload, mediaPayload) {
|
||||
t.Fatalf("downloaded payload = %q, want %q", gotPayload, mediaPayload)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
|
||||
t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||
}
|
||||
if len(result) != 3 || result["success"] != true || result["downloadUrl"] != downloadURL || result["output"] != wantOutput {
|
||||
t.Fatalf("CLI result = %#v, want exact success/downloadUrl/output contract", result)
|
||||
}
|
||||
if strings.Contains(stdout, "[INFO]") {
|
||||
t.Fatalf("JSON stdout contains progress text: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "?fixture=one&part=two") {
|
||||
t.Fatalf("downloadUrl was escaped or changed: %s", stdout)
|
||||
}
|
||||
|
||||
publicResult := map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": "http://127.0.0.1:<fixture-port>/photo.jpg?fixture=one&part=two",
|
||||
"output": "./downloads/photo.jpg",
|
||||
}
|
||||
var publicOutput bytes.Buffer
|
||||
publicEncoder := json.NewEncoder(&publicOutput)
|
||||
publicEncoder.SetEscapeHTML(false)
|
||||
publicEncoder.SetIndent("", " ")
|
||||
if err := publicEncoder.Encode(publicResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("CLI (public-safe): dws chat message download-media --type mediaId --resource-id resource-001 --message-id message-001 --open-conversation-id conversation-001 --output ./downloads/ --format json")
|
||||
t.Logf("stdout (public-safe):\n%s", strings.TrimSpace(publicOutput.String()))
|
||||
t.Logf("downloaded file: photo.jpg (%d bytes, content verified)", len(gotPayload))
|
||||
}
|
||||
|
||||
func isolatedCLIEnv(t *testing.T, extra map[string]string) []string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user