fix(chat): align message workflows and diagnostics

This commit is contained in:
克谨
2026-08-07 15:48:29 +08:00
parent 7c76dfea4b
commit effe7c829e
24 changed files with 677 additions and 47 deletions
@@ -25,7 +25,16 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
const declared = "contract_final"
wants := []finalSchemaSafetyWant{
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
{canonical: "chat.create_and_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.send_message_by_custom_robot", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.send_personal_message", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.send_robot_message", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
+1 -1
View File
@@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") {
t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
+16 -2
View File
@@ -19,6 +19,7 @@ import (
stderrors "errors"
"fmt"
"io"
"net/url"
"strings"
"time"
@@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
}
if actionURL != "" {
lines = append(lines, tui.White("Action: 开启地址: "+actionURL))
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
}
}
@@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
func serverGuidance(diag ServerDiagnostics) (string, string) {
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
actionURL := strings.TrimSpace(diag.ActionURL)
actionURL := safeServerActionURL(diag.ActionURL)
if friendlyHint == "" || actionURL == "" {
switch diag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
@@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
return friendlyHint, actionURL
}
func safeServerActionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
parsed.Hostname() == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
func category(err error) string {
var typed *Error
if stderrors.As(err, &typed) {
+30 -1
View File
@@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") {
t.Fatalf("expected server guidance in output, got %q", got)
}
if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") {
if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") {
t.Fatalf("expected server action URL in output, got %q", got)
}
}
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
t.Parallel()
for _, actionURL := range []string{
"http://example.test/help",
"javascript:alert(1)",
"https://user:secret@example.test/help",
"not a url",
} {
var human strings.Builder
err := NewAPI("server error", WithServerDiag(ServerDiagnostics{
FriendlyHint: "保留 Trace ID 后排查",
ActionURL: actionURL,
}))
if printErr := PrintHuman(&human, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") {
t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String())
}
var jsonOutput strings.Builder
if printErr := PrintJSON(&jsonOutput, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(jsonOutput.String(), `"action_url"`) {
t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String())
}
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
+22 -12
View File
@@ -2528,7 +2528,7 @@ func newChatCommand() *cobra.Command {
DeclareLeafMetadata(chatMessageSendCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2546,7 +2546,7 @@ func newChatCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送群聊或单聊消息",
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时"},
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"},
AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"},
Examples: []string{"dws chat message send --group <openConversationId> \"项目已更新\""},
},
@@ -2728,7 +2728,7 @@ func newChatCommand() *cobra.Command {
DeclareLeafMetadata(chatMessageSendByBotCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2889,7 +2889,14 @@ func newChatCommand() *cobra.Command {
DeclareLeafMetadata(chatMessageSendByWebhookCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
// The webhook path parses the raw response directly instead of dispatching
// through deps.Caller.CallTool. Supplying a local validator makes the
// framework confirm before RunE, so the HTTP write can never happen before
// the user_required gate.
Validate: func(cmd *cobra.Command, _ []string) error {
return validateRequiredFlags(cmd, "token", "title", "text")
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -3587,11 +3594,13 @@ func newChatCommand() *cobra.Command {
// ── query-send-status:查询消息发送状态(走 IM MCP)──────
chatMessageQuerySendStatusCmd := &cobra.Command{
Use: "query-send-status",
Short: "查询消息发送状态",
Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`,
Use: "query-send-status",
Aliases: []string{"send-status"},
Short: "查询消息发送状态",
Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。
投递成功后,响应中的 openMessageId 和 openConversationId 可继续用于 edit、recall、read-status 等消息操作;openTaskId 本身不是消息 ID。`,
Example: ` dws chat message query-send-status --open-task-id <openTaskId>
# openTaskId 由 dws chat message send 返回`,
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "open-task-id"); err != nil {
return err
@@ -3613,6 +3622,7 @@ func newChatCommand() *cobra.Command {
CanonicalPath: "chat.query_message_send_status",
CLIPath: "chat message query-send-status",
PrimaryCLIPath: "chat message query-send-status",
Aliases: []string{"chat message send-status"},
},
Description: "查询异步消息发送任务的状态",
Interface: &contract.InterfaceSpec{
@@ -3622,8 +3632,8 @@ func newChatCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询异步消息发送任务的状态",
UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果时"},
AvoidWhen: []string{"没有 openTaskId 或只需查消息内容时不要使用"},
UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果,或后续 edit/recall/read-status 需要先取得 openMessageId 和 openConversationId 时"},
AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"},
Examples: []string{"dws chat message query-send-status --open-task-id <openTaskId>"},
},
},
@@ -5424,7 +5434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
DeclareLeafMetadata(chatMessageSendCardCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -5518,7 +5528,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
DeclareLeafMetadata(chatMessageUpdateCardCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -29,6 +29,9 @@ func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...strin
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "skip confirmation")
}
root.SetArgs(args)
return root.Execute()
}
@@ -41,6 +44,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
"--yes",
)
if err != nil {
t.Fatal(err)
@@ -60,6 +64,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
"--biz-id", "not-a-real-card",
"--content", "完成",
"--flow-status", "3",
"--yes",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
+1 -1
View File
@@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
root.SetArgs(append(append([]string(nil), args...), "--yes"))
return root.ExecuteContext(context.Background())
}
@@ -0,0 +1,66 @@
// 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.
package helpers
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageChatMessageWritesRequireConfirmationBeforeDispatch(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "current user send", args: []string{"message", "send", "--group", "cid-1", "--text", "hello"}},
{name: "bot send", args: []string{"message", "send-by-bot", "--robot-code", "robot-1", "--group", "cid-1", "--title", "title", "--text", "hello"}},
{name: "webhook send", args: []string{"message", "send-by-webhook", "--token", "token-1", "--title", "title", "--text", "hello"}},
{name: "card send", args: []string{"message", "send-card", "--group", "cid-1"}},
{name: "card update", args: []string{"message", "update-card", "--biz-id", "biz-1", "--content", "done", "--flow-status", "3"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
InitDepsForTest(t, caller)
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetIn(strings.NewReader(""))
cmd.SetArgs(test.args)
err := cmd.Execute()
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("Execute() error = %#v, want confirmation_required", err)
}
if caller.calls != 0 {
t.Fatalf("downstream calls = %d, want 0 before confirmation", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatMessageWriteLeavesInstallRuntimeConfirmation(t *testing.T) {
chat := newChatCommand()
for _, path := range [][]string{
{"message", "send"},
{"message", "send-by-bot"},
{"message", "send-by-webhook"},
{"message", "send-card"},
{"message", "update-card"},
} {
leaf, _, err := chat.Find(path)
if err != nil || leaf == nil {
t.Fatalf("Find(%v) = %v, %v", path, leaf, err)
}
if !HasContractConfirmSafety(leaf) {
t.Errorf("%s has no contract confirmation runtime guard", strings.Join(path, " "))
}
}
}
+4 -1
View File
@@ -409,9 +409,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
if cmd.PersistentFlags().Lookup("yes") == nil {
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
}
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
return cmd.Execute()
}
+2 -2
View File
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location",
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes")
if err != nil {
t.Fatal(err)
}
@@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes")
if err != nil {
t.Fatal(err)
}
+59 -18
View File
@@ -1003,9 +1003,10 @@ func uniqueShortcutStrings(values []string) []string {
var MessagesQuerySendStatus = shortcut.Shortcut{
Service: "chat",
Command: "+messages-query-send-status",
Aliases: []string{"+messages-send-status"},
Product: "im",
Description: "查询消息发送状态",
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Description: "查询消息投递状态并衔接后续消息操作",
Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -1018,17 +1019,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
CanonicalPath: "chat.shortcut_messages_query_send_status",
CLIPath: "chat +messages-query-send-status",
PrimaryCLIPath: "chat +messages-query-send-status",
Aliases: []string{"chat +messages-send-status"},
},
Description: "查询消息发送状态",
Description: "查询消息投递状态并衔接后续消息操作",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询消息发送状态",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "查询消息投递状态并衔接后续消息操作",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"},
AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"},
Examples: []string{"dws chat +messages-query-send-status --open-task-id <openTaskId>"},
},
},
@@ -1037,7 +1039,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
taskID := rt.Str("open-task-id")
data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID})
if err != nil {
return err
}
return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID))
},
}
@@ -1320,7 +1327,28 @@ var MessagesSendCard = shortcut.Shortcut{
}
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": chatmsg.StreamingCardContractVersion,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 1,
"actions": []map[string]any{{
"tool": "create_and_send_card",
"arguments": params,
}},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return cardCreateMissingBizIDError(created)
}
return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID))
}
status := rt.Int("flow-status")
if rt.DryRun() {
@@ -1366,14 +1394,11 @@ var MessagesSendCard = shortcut.Shortcut{
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
payload["bizId"] = bizID
payload["flowStatus"] = status
payload["updated"] = updated
return rt.Output(payload)
},
}
@@ -1510,13 +1535,29 @@ var MessagesUpdateCard = shortcut.Shortcut{
if err != nil {
return err
}
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
if err != nil {
return cardUpdateVerificationError(bizID, err)
}
return rt.Output(updated)
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
},
}
func cardCreateMissingBizIDError(created map[string]any) error {
return apperrors.NewAPI(
"卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续",
apperrors.WithOperation("create_and_send_card"),
apperrors.WithServerKey("im"),
apperrors.WithOrigin("client_postcondition"),
apperrors.WithFailureStage("verify_card_reference"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason("streaming_card_reference_missing"),
apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"),
apperrors.WithDetails(map[string]any{"created": created}),
)
}
func cardUpdateVerificationError(bizID string, verifyErr error) error {
reason := "streaming_card_update_unverified"
message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败"
+58
View File
@@ -20,6 +20,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
type chatOutputErrorWriter struct {
@@ -124,6 +125,63 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB
}
}
func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
}
func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user", "--chat-id", "cid-1",
"--text", "hello", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
receipt, _ := payload["sendReceipt"].(map[string]any)
if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" {
t.Fatalf("sendReceipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]any)
if len(actions) != 1 {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -74,6 +74,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/create_and_send_card":
text = `{"result":{"bizId":"biz-created"}}`
case "im/list_messages_by_ids":
text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}`
case "im/list_conversations_by_category":
+11 -4
View File
@@ -19,6 +19,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
@@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": messagesSendIdentity(rt),
"tool": tool,
"result": data,
})
}
if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" {
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
}
return rt.Output(payload)
}
func messagesSendIdentity(rt *shortcut.RuntimeContext) string {
@@ -612,7 +617,7 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
@@ -624,7 +629,9 @@ func executeMessagesSendUserFile(
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
return rt.Output(payload)
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
+65
View File
@@ -0,0 +1,65 @@
// 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.
package chatmsg
import "strings"
// StreamingCardContractVersion identifies the additive card receipt emitted by
// high-level card shortcuts.
const StreamingCardContractVersion = "im.streaming-card.v1"
// ProjectStreamingCardReceipt publishes every server-returned identifier in a
// single cardRef. referencePairAvailable means this response contained both
// the update identifier and the visible message identifiers; it does not claim
// that older messages can be resolved without server-side mapping support.
func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any {
bizID = strings.TrimSpace(bizID)
messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid")
cardRef := map[string]any{}
if bizID != "" {
cardRef["bizId"] = bizID
}
if messageID != "" {
cardRef["openMessageId"] = messageID
}
if conversationID != "" {
cardRef["openConversationId"] = conversationID
}
pairAvailable := bizID != "" && messageID != "" && conversationID != ""
payload := map[string]any{
"contractVersion": StreamingCardContractVersion,
"ok": true,
"cardRef": cardRef,
"referencePairAvailable": pairAvailable,
"created": created,
"nextActions": []map[string]any{},
}
if bizID != "" {
payload["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-update-card",
"arguments": map[string]any{
"biz-id": bizID,
},
"requiredArguments": []string{"content", "flow-status"},
"ready": false,
}}
}
if !pairAvailable {
payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId"
}
return payload
}
// ProjectStreamingCardUpdate preserves the lower response while making the
// verified target explicit for downstream consumers.
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
payload := cloneSendStatusMap(updated)
payload["contractVersion"] = StreamingCardContractVersion
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
payload["verified"] = true
payload["verificationEvidence"] = proof
return payload
}
@@ -0,0 +1,42 @@
// 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.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
complete := ProjectStreamingCardReceipt(map[string]any{
"result": map[string]any{
"bizId": "biz-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
},
}, "biz-1")
if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true {
t.Fatalf("complete receipt = %#v", complete)
}
ref, _ := complete["cardRef"].(map[string]any)
if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("cardRef = %#v", ref)
}
if _, exists := complete["capabilityGap"]; exists {
t.Fatalf("complete receipt has capability gap: %#v", complete)
}
partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2")
if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" {
t.Fatalf("partial receipt = %#v", partial)
}
}
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["result"]; !exists {
t.Fatal("lower response was not preserved")
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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.
package chatmsg
import (
"strings"
)
// MessageSendStatusContractVersion identifies the additive workflow fields
// projected by the high-level send-status shortcut. The lower response fields
// remain at their original locations for compatibility.
const MessageSendStatusContractVersion = "im.message-send-status.v1"
// MessageSendReceiptContractVersion identifies the additive receipt attached
// to high-level current-user send results.
const MessageSendReceiptContractVersion = "im.message-send-receipt.v1"
// ProjectMessageSendReceipt connects a send result to its asynchronous status
// query without treating openTaskId as a message identifier.
func ProjectMessageSendReceipt(raw map[string]any) map[string]any {
taskID := firstSendStatusString(raw, "openTaskId", "taskId")
messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid")
ready := messageID != "" && conversationID != ""
receipt := map[string]any{
"contractVersion": MessageSendReceiptContractVersion,
"openTaskId": taskID,
"readyForMessageActions": ready,
"nextActions": []map[string]any{},
}
if messageID != "" || conversationID != "" {
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
receipt["messageRef"] = messageRef
}
switch {
case ready:
receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true)
case taskID != "":
receipt["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": true,
"when": "需要确认投递结果或取得真实消息 ID 时",
}}
default:
receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询"
}
return receipt
}
// ProjectMessageSendStatus preserves the lower response and adds a stable
// receipt that connects openTaskId to the message identifiers required by
// edit, recall, and read-status. It never manufactures a message reference:
// downstream actions are marked ready only when both IDs are actually present.
func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any {
payload := cloneSendStatusMap(raw)
taskID := firstSendStatusString(payload, "openTaskId", "taskId")
if taskID == "" {
taskID = strings.TrimSpace(requestedTaskID)
}
messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid")
payload["contractVersion"] = MessageSendStatusContractVersion
payload["openTaskId"] = taskID
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
if len(messageRef) > 0 {
payload["messageRef"] = messageRef
}
ready := messageID != "" && conversationID != ""
payload["readyForMessageActions"] = ready
payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready)
return payload
}
func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any {
if !ready {
return []map[string]any{{
"cliPath": "chat message query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": false,
"when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查",
}}
}
messageArgs := map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
}
return []map[string]any{
{
"cliPath": "chat message recall",
"arguments": cloneSendStatusMap(messageArgs),
"ready": true,
},
{
"cliPath": "chat message edit",
"arguments": map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
},
"requiredArguments": []string{"text 或 content"},
"ready": false,
},
{
"cliPath": "chat message read-status",
"arguments": map[string]any{
"conversation-id": conversationID,
"message-id": messageID,
},
"ready": true,
},
}
}
func firstSendStatusString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
}
for _, key := range []string{"result", "data", "response", "content", "message"} {
if candidate := firstSendStatusString(typed[key], keys...); candidate != "" {
return candidate
}
}
case []any:
for _, item := range typed {
if candidate := firstSendStatusString(item, keys...); candidate != "" {
return candidate
}
}
}
return ""
}
func cloneSendStatusMap(source map[string]any) map[string]any {
clone := make(map[string]any, len(source)+5)
for key, value := range source {
clone[key] = value
}
return clone
}
@@ -0,0 +1,63 @@
// 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.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) {
receipt := ProjectMessageSendReceipt(map[string]any{
"result": map[string]any{"openTaskId": "task-1"},
})
if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false {
t.Fatalf("receipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) {
raw := map[string]any{
"result": map[string]any{
"openTaskId": "task-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"status": "SUCCESS",
},
}
payload := ProjectMessageSendStatus(raw, "ignored")
if payload["contractVersion"] != MessageSendStatusContractVersion ||
payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" {
t.Fatalf("nextActions = %#v", actions)
}
if _, ok := payload["result"]; !ok {
t.Fatal("raw response field was not preserved")
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) {
payload := ProjectMessageSendStatus(map[string]any{
"result": map[string]any{"status": "PENDING"},
}, "task-pending")
if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["messageRef"]; exists {
t.Fatalf("pending payload invented messageRef: %#v", payload)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["ready"] != false {
t.Fatalf("nextActions = %#v", actions)
}
}
+2
View File
@@ -1047,6 +1047,7 @@ func authActions(snapshotPath string) []string {
func runtimeActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查认证、权限和参数后重试原命令"),
i18n.T("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
}
_ = snapshotPath
return actions
@@ -1056,6 +1057,7 @@ func networkActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
i18n.T("运行 dws doctor 获取可共享的环境诊断结果"),
}
_ = snapshotPath
return actions
+2 -2
View File
@@ -397,13 +397,13 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) {
t.Fatalf("HTTP %d returned nil", tc.code)
}
}
if got := networkActions("snap"); len(got) != 2 {
if got := networkActions("snap"); len(got) != 3 {
t.Fatalf("network actions = %#v", got)
}
if got := authActions("snap"); len(got) != 2 {
t.Fatalf("auth actions = %#v", got)
}
if got := runtimeActions("snap"); len(got) != 1 {
if got := runtimeActions("snap"); len(got) != 2 {
t.Fatalf("runtime actions = %#v", got)
}
if got := actionsForMethod("tools/call", "snap"); len(got) == 0 {
+33 -3
View File
@@ -42,18 +42,48 @@ func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDia
return apperrors.ServerDiagnostics{}
}
diag := apperrors.ServerDiagnostics{
TraceID: stringFromMap(content, "trace_id", "traceId"),
TraceID: stringFromMapRecursive(content, 0, "trace_id", "traceId", "request_id", "requestId"),
ServerErrorCode: serverErrorCodeFromMap(content, 0),
TechnicalDetail: stringFromMap(content, "technical_detail"),
TechnicalDetail: stringFromMapRecursive(content, 0, "technical_detail", "technicalDetail"),
FriendlyHint: stringFromMapRecursive(content, 0, "friendly_hint", "friendlyHint"),
ActionURL: stringFromMapRecursive(content, 0, "action_url", "actionUrl"),
}
if v, ok := content["retryable"].(bool); ok {
if v, ok := boolFromMapRecursive(content, 0, "retryable", "server_retryable", "serverRetryable"); ok {
diag.ServerRetryable = &v
}
return diag
}
func boolFromMapRecursive(content map[string]any, depth int, keys ...string) (bool, bool) {
if content == nil || depth > 8 {
return false, false
}
for _, key := range keys {
if value, ok := content[key].(bool); ok {
return value, true
}
}
for _, key := range []string{"content", "result", "data"} {
switch child := content[key].(type) {
case map[string]any:
if value, ok := boolFromMapRecursive(child, depth+1, keys...); ok {
return value, true
}
case []any:
for _, item := range child {
childMap, ok := item.(map[string]any)
if !ok {
continue
}
if value, ok := boolFromMapRecursive(childMap, depth+1, keys...); ok {
return value, true
}
}
}
}
return false, false
}
func stringFromMapRecursive(content map[string]any, depth int, keys ...string) string {
if content == nil || depth > 8 {
return ""
+18
View File
@@ -110,6 +110,24 @@ func TestExtractServerDiagnosticsFromMapNestedBusinessCode(t *testing.T) {
}
}
func TestExtractServerDiagnosticsFromMapNestedDiagnosticEnvelope(t *testing.T) {
t.Parallel()
content := map[string]any{
"result": map[string]any{
"data": map[string]any{
"requestId": "request-nested",
"technicalDetail": "nested detail",
"serverRetryable": false,
},
},
}
diag := ExtractServerDiagnosticsFromMap(content)
if diag.TraceID != "request-nested" || diag.TechnicalDetail != "nested detail" ||
diag.ServerRetryable == nil || *diag.ServerRetryable {
t.Fatalf("nested diagnostics = %#v", diag)
}
}
func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnosticsFromMap(nil)