Compare commits

...
5 changed files with 299 additions and 7 deletions
+6
View File
@@ -0,0 +1,6 @@
---
category: Fixed
---
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
@@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}
}
func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
tests := []struct {
name string
body string
atIDs string
atAll bool
wantErr string
}{
{
name: "declared member missing from body",
body: "请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "declared member only appears as a longer token prefix",
body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "body member missing from declaration",
body: "<@" + fixtureCurrentDOpenID + "> 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
{
name: "body at-all missing from declaration",
body: "<@all> 请查收",
wantErr: "必须同时指定 --at-all",
},
{
name: "bare body member missing from declaration",
body: "@" + fixtureCurrentDOpenID + " 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := []string{
"chat", "+messages-send", "--identity", "user",
"--group", "cid", "--markdown", tt.body, "--yes",
}
if tt.atIDs != "" {
args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
}
if tt.atAll {
args = append(args, "--at-all")
}
root.SetArgs(args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
}
})
}
}
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
for i := range flags {
+113
View File
@@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
if _, err := io.Copy(io.Discard, r.Body); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
tests := []struct {
name string
targetArgs []string
requestedType string
wantOpenID string
wantUserResolve bool
}{
{
name: "explicit open id file",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID},
requestedType: "file",
wantOpenID: fixtureCurrentDOpenID,
},
{
name: "resolved user audio",
targetArgs: []string{"--user", "user-id"},
requestedType: "audio",
wantOpenID: "D-resolved",
wantUserResolve: true,
},
{
name: "explicit open id video",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2},
requestedType: "video",
wantOpenID: fixtureCurrentDOpenID2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := []string{
"chat", "+messages-send", "--identity", "user",
"--msg-type", tt.requestedType,
"--file", "./direct.bin",
"--idempotency-key", "direct-" + tt.requestedType,
"--yes",
}
args = append(args, tt.targetArgs...)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
callOffset := 0
if tt.wantUserResolve {
if len(fake.calls) == 0 || fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls)
}
callOffset = 1
}
if len(fake.calls) != callOffset+3 {
t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls)
}
initCall := fake.calls[callOffset]
commitCall := fake.calls[callOffset+1]
sendCall := fake.calls[callOffset+2]
for _, call := range []larkAlignmentCall{initCall, commitCall} {
if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID {
t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID)
}
if _, exists := call.args["receiverOpenDingTalkId"]; exists {
t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args)
}
}
if initCall.tool != "init_conversation_file_upload" ||
commitCall.tool != "commit_conversation_file_upload" {
t.Fatalf("upload calls = %#v, %#v", initCall, commitCall)
}
if sendCall.product != "chat" || sendCall.tool != "send_personal_message" ||
sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID {
t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID)
}
if _, exists := sendCall.args["openDingTalkId"]; exists {
t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != tt.requestedType ||
payload["effectiveMessageType"] != "file" {
t.Fatalf("output = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}
}
if tt.bodyKey == "content" {
rawContent := call.args[tt.bodyKey].(string)
if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) {
t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent)
}
if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") {
t.Errorf("content = %q; missing literal current-user mention token", rawContent)
}
var content map[string]string
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
+107 -5
View File
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -18,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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/jsonutil"
"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"
@@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
if err := validateCurrentUserMentionConsistency(
messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
return err
}
}
case "bot":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
@@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
return nil
}
func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
declared := make(map[string]struct{}, len(atOpenIDs))
for _, id := range atOpenIDs {
declared[id] = struct{}{}
}
missingPlaceholders := make([]string, 0)
for _, id := range atOpenIDs {
if !containsCurrentUserMentionToken(body, id) {
missingPlaceholders = append(missingPlaceholders, id)
}
}
if len(missingPlaceholders) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
strings.Join(missingPlaceholders, ","),
))
}
undeclared := make([]string, 0)
for _, id := range currentUserMentionBodyIDs(body) {
if _, ok := declared[id]; !ok {
undeclared = append(undeclared, id)
}
}
if len(undeclared) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
strings.Join(undeclared, ","),
))
}
if !atAll && containsCurrentUserMentionToken(body, "all") {
return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
}
return nil
}
func containsCurrentUserMentionToken(body, id string) bool {
placeholder := "@" + id
for searchFrom := 0; ; {
offset := strings.Index(body[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(body) {
return true
}
next, _ := utf8.DecodeRuneInString(body[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func currentUserMentionBodyIDs(body string) []string {
ids := make([]string, 0)
for searchFrom := 0; ; {
offset := strings.IndexByte(body[searchFrom:], '@')
if offset < 0 {
break
}
start := searchFrom + offset + 1
end := start
for end < len(body) {
current := body[end]
if (current < 'a' || current > 'z') &&
(current < 'A' || current > 'Z') &&
(current < '0' || current > '9') {
break
}
end++
}
id := body[start:end]
if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
ids = appendUniqueShortcutString(ids, id)
}
searchFrom = end
}
return ids
}
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
body := messagesSendBody(rt)
@@ -499,7 +590,7 @@ func resolvedUserMarkdownParams(
if target.GroupID != "" {
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
@@ -573,8 +664,10 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
uploadTargetArgs := map[string]any{}
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
sendTargetArgs := map[string]any{}
addMessagesSendUserTarget(sendTargetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
@@ -587,6 +680,7 @@ func executeMessagesSendUserFile(
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"target": uploadTargetArgs,
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
@@ -598,7 +692,7 @@ func executeMessagesSendUserFile(
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": targetArgs,
"target": sendTargetArgs,
},
},
})
@@ -607,7 +701,7 @@ func executeMessagesSendUserFile(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, targetArgs, meta, idempotencyKey)
uploadContext, uploadTargetArgs, meta, idempotencyKey)
if err != nil {
return err
}
@@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) {
params["receiverOpenDingTalkId"] = openID
}
func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["openDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {