Compare commits

...
Author SHA1 Message Date
栩朝 7a95659133 fix(chat): harden openDingTalkId target routing 2026-08-13 19:38:25 +08:00
27 changed files with 1691 additions and 223 deletions
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
+4 -4
View File
@@ -322,7 +322,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--to-user", appFixtureCurrentDOpenID,
"--text", "hello alias",
"--uuid", "alias-e2e",
)
@@ -336,7 +336,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
@@ -627,11 +627,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
@@ -12,6 +12,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
@@ -32,12 +37,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -55,7 +60,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
@@ -68,10 +73,10 @@ var paramAliasCompleteCommands = map[string][]string{
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
@@ -86,9 +91,9 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
@@ -160,7 +165,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
+25 -5
View File
@@ -491,6 +491,11 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if senderUserID == "" && senderOpenDingTalkID == "" {
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
if senderOpenDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
return nil, err
}
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
return nil, err
@@ -993,8 +998,7 @@ func isNumericUserID(value string) bool {
}
func isOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
}
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
@@ -2636,6 +2640,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2723,6 +2732,11 @@ func newChatCommand() *cobra.Command {
if userID == "" && openDingTalkID == "" {
return fmt.Errorf("--user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2852,6 +2866,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -4667,9 +4686,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
userID := rawUserID
openDingTalkID := rawOpenDingTalkID
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
userID = openDingTalkID
openDingTalkID = ""
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
+25 -4
View File
@@ -14,6 +14,11 @@ import (
"github.com/spf13/cobra"
)
const (
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func newChatFlagTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "chat"}
cmd.Flags().String("forward", "", "")
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
return cmd
}
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
userIDs, openIDs := splitChatIDValues([]string{
helperCurrentDOpenID,
"D-prefix-fixture-user",
"d-prefix-fixture-user",
"D-invalid",
"fixture-user-id",
})
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
t.Fatalf("open IDs=%#v", openIDs)
}
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
t.Fatalf("user IDs=%#v", userIDs)
}
}
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
search := &cobra.Command{Use: "search"}
search.Flags().String("nicks", "", "")
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
t.Fatalf("direct ID = %q, %v", id, err)
}
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
t.Fatal("empty ID unexpectedly resolved")
}
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
t.Fatalf("direct IDs = %#v, %v", ids, err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
installScriptedCaller(t, caller)
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
t.Fatalf("resolved IDs = %#v, %v", ids, err)
}
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
+4 -4
View File
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--open-dingtalk-id=D-target",
"--open-dingtalk-id=" + helperCurrentDOpenID,
"--msg-type=file",
"--file-path=" + filePath,
}
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"uploadKey": "upload-key",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send direct target call = %#v", send)
}
if send.args["receiverOpenDingTalkId"] != "D-target" {
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
t.Fatalf("send direct target = %#v", send.args)
}
if _, ok := send.args["openDingTalkId"]; ok {
+26 -26
View File
@@ -786,11 +786,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
},
}
@@ -819,11 +819,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
}
@@ -857,25 +857,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply",
@@ -883,15 +883,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--ref-sender", helperCurrentDOpenID,
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "reply adds missing member placeholders",
@@ -899,13 +899,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
"--ref-sender", helperCurrentDOpenID,
"--text", "DWS synthetic reply mention test",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
},
{
name: "reply adds missing member placeholders after at-all",
@@ -913,15 +913,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply at-all preserves alliance word",
@@ -929,7 +929,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
"--at-all",
},
@@ -943,7 +943,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
},
contentField: "content",
+17 -4
View File
@@ -24,13 +24,14 @@ import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
// mirroring helpers.isOpenDingTalkID.
// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical
// current-D wire format shared with helpers and smart shortcuts.
func isOpenID(v string) bool {
v = strings.TrimSpace(v)
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(v)
}
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
@@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) {
return userIDs, openIDs
}
func validateExplicitOpenIDs(flagName string, values []string) error {
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil {
return err
}
}
return nil
}
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
func toInt64Slice(vals []string) ([]int64, error) {
out := make([]int64, 0, len(vals))
@@ -25,6 +25,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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"
)
// ConversationInfo gets conversation info (get_conversation_info, chat server).
@@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{
params["openConversationId"] = rt.Str("group")
}
if rt.Str("open-dingtalk-id") != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
}
if len(params) == 0 {
+3
View File
@@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil {
return err
}
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
+18
View File
@@ -29,6 +29,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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"
)
const directMessagesHardPageLimit = 500
@@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"openConversationId": rt.Str("group"),
@@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": rt.Str("title"),
@@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
}
switch {
case rt.Str("open-dingtalk-id") != "":
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
@@ -1515,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
@@ -26,6 +26,11 @@ import (
"github.com/spf13/cobra"
)
const (
fixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
type platformCoverageCaller struct {
product string
tool string
@@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}`
case "chat/get_group_members":
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}`
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}`
case "im/set_group_member_mute_list":
text = `{"success":true}`
default:
@@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
value string
want bool
}{
{value: "DingTalk-open-id", want: true},
{value: " dingtalk-open-id ", want: true},
{value: "DAAAAAAAAAAAiE", want: true},
{value: " DAAAAAAAAAAAiE ", want: true},
{value: "D-prefix-fixture-user", want: false},
{value: "d-prefix-fixture-user", want: false},
{value: "user-id", want: false},
{value: " ", want: false},
}
@@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
})
t.Run("split mixed IDs", func(t *testing.T) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"})
if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) {
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
}
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) {
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
}
})
@@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
root.SetArgs([]string{
"chat", "+chat-mute-member",
"--group", "cid-1",
"--users", "user-2,D-open-2",
"--users", "user-2," + fixtureCurrentDOpenID2,
"--mute-time", "300000",
"--yes",
})
@@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
if _, ok := final.args["uids"]; ok {
t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args)
}
if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) {
if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("openDingTalkIds = %#v, want %#v", got, want)
}
}
@@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
{
name: "reply write",
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
wantError: "fixture lower call failed",
},
}
@@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
}}
helpers.InitDeps(external)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
cases := [][]string{
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "user", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"},
{"--identity", "bot", "--group", "cid", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
{"--identity", "webhook", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
}
for _, tail := range cases {
@@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
name: "user direct",
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": "D1"},
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
},
{
name: "bot group mentions",
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
},
{
name: "bot direct targets",
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"},
},
{
name: "webhook mentions",
@@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
root.SetArgs([]string{
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
}
for _, call := range fake.calls {
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
call.args["isAtAll"] != "true" {
t.Fatalf("batch mention args = %#v", call.args)
}
+49 -20
View File
@@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
"--owner-open-dingtalk-id", "D-owner", "--yes",
"chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1",
"--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
t.Fatalf("explicit owner calls = %#v", fake.calls)
}
create := fake.calls[0].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
"contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
"--owner-query", "张三", "--yes",
"--owner-query", "测试用户甲", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *
t.Fatalf("owner query calls = %#v", fake.calls)
}
create := fake.calls[1].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
@@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--open-dingtalk-id", fixtureCurrentDOpenID,
"--file", "./fixture.bin",
"--dry-run",
"--yes",
@@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID,
"--at-all",
"--content", "完成",
"--flow-status", "3",
@@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
@@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--content", "正文",
"--yes",
})
@@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2,
"--yes",
})
if err := root.Execute(); err != nil {
@@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
@@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--at-all",
"--content", "处理中",
"--dry-run",
@@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
@@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
{"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
{"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil {
t.Fatalf("blank and valid IDs: %v", err)
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
for _, args := range [][]string{
{"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"},
{"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"},
{"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"},
{"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"},
{"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
+7 -2
View File
@@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil {
return err
}
}
resolvedMembers, err := targetresolver.ResolveUsers(
rt,
rt.StrSlice("member-query"),
@@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string {
"senderOpenDingtalkId",
"senderOpenId",
} {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
body: "<@all> hello <@" + fixtureCurrentDOpenID + ">",
},
{
name: "bot",
@@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--idempotency-key", "reply-uuid",
"--yes",
@@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
t.Fatal(err)
}
if content["referenceOpenMessageId"] != "msg" ||
content["srcMsgSendOpenDingTalkId"] != "D-sender" ||
content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID ||
content["replyMsgType"] != "text" ||
content["content"] != "收到" {
t.Fatalf("reply content = %#v", content)
@@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T)
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--dry-run",
"--yes",
@@ -56,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
+11
View File
@@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
return err
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
return err
}
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
+228 -42
View File
@@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{
Command: "+chat-messages",
Product: "chat",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;自动唯一解析并筛选同一次会话读取结果"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
@@ -133,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
@@ -223,105 +225,267 @@ type chatMessagesRequest struct {
}
type chatMessagesSenderFilter struct {
requested bool
applied bool
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
failure map[string]any
requested bool
applied bool
inputs []string
inputMode string
stableIDs map[string]bool
hasUserIDs bool
hasOpenIDs bool
resolutions []targetresolver.UserResolution
resolutionFailure map[string]any
resolutionErr error
scopeErr error
}
// resolveOptionalChatMessagesSenderFilter is deliberately local to
// +chat-messages. Sender resolution is an optional post-read filter here: an
// unresolved name must not suppress the command's primary conversation-list
// result. +search-msg keeps its stricter semantics because sender identity is
// part of the lower search request there.
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
// as +search-msg while retaining --sender-query as a compatibility entry. It
// resolves only explicitly requested senders; it never enumerates every member
// or tries to infer a person from message display names.
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
queries := rt.StrSlice("sender-query")
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
inputs := direct
inputMode := "sender"
if len(inputs) == 0 {
inputs = queries
inputMode = "sender-query"
}
filter := chatMessagesSenderFilter{
requested: len(queries) > 0,
requested: len(inputs) > 0,
inputs: inputs,
inputMode: inputMode,
stableIDs: map[string]bool{},
}
if !filter.requested {
return filter
}
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
var resolutions []targetresolver.UserResolution
var err error
if len(direct) > 0 {
for _, value := range direct {
var resolved targetresolver.UserResolution
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
if err != nil {
break
}
resolutions = append(resolutions, resolved)
}
} else {
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
}
if err != nil {
failure := map[string]any{
"stage": "sender_resolution",
"queries": queries,
"error": err.Error(),
"stage": "sender_resolution",
"inputs": inputs,
"mode": inputMode,
"error": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
if typed.Reason != "" {
failure["reason"] = typed.Reason
}
if typed.Hint != "" {
failure["hint"] = typed.Hint
}
if len(typed.Details) > 0 {
failure["details"] = typed.Details
}
}
filter.failure = failure
filter.resolutionFailure = failure
filter.resolutionErr = err
return filter
}
filter.resolutions = resolutions
for _, resolution := range resolutions {
for _, identity := range []string{
resolution.Selected.UserID,
resolution.Selected.OpenDingTalkID,
} {
if identity = strings.TrimSpace(identity); identity != "" {
filter.stableIDs[identity] = true
}
}
}
// ResolveUsers(IdentityAny) only returns users extracted with at least one
// stable userId/openDingTalkId and fails when no resolvable user remains.
filter.hasUserIDs, filter.hasOpenIDs = addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
filter.applied = true
return filter
}
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
// when optional resolution fails. Successful resolution filters both the
func uniqueChatMessageTargets(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) (bool, bool) {
hasUserIDs := false
hasOpenIDs := false
for _, resolution := range resolutions {
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
ids[identity] = true
hasUserIDs = true
}
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
ids[identity] = true
hasOpenIDs = true
}
}
return hasUserIDs, hasOpenIDs
}
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
// requested sender cannot be resolved. Successful resolution filters both the
// public projection and raw rows so exports and resource downloads cannot
// accidentally include messages outside the requested sender set.
func applyOptionalChatMessagesSenderFilter(
rt *shortcut.RuntimeContext,
payload map[string]any,
rawItems []map[string]any,
filter chatMessagesSenderFilter,
filter *chatMessagesSenderFilter,
) []map[string]any {
if !filter.requested || payload == nil {
if filter == nil || !filter.requested || payload == nil {
return rawItems
}
if !filter.applied {
failures, _ := payload["failures"].([]map[string]any)
priorFailures := len(failures)
failures = append(failures, filter.failure)
failure := filter.resolutionFailure
if failure == nil {
failure = map[string]any{
"stage": "sender_resolution",
"inputs": filter.inputs,
"mode": filter.inputMode,
"error": "无法把发送者目标唯一解析为稳定身份",
}
}
failures = append(failures, failure)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(rawItems) > 0
payload["partial"] = false
payload["messages"] = []map[string]any{}
payload["count"] = 0
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": false,
"status": "resolution_failed",
"suppressedCount": len(rawItems),
}
payload["identityResult"] = map[string]any{
"status": "resolution_failed",
"nameComparisonAllowed": false,
"stableIdField": "senderId",
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
}
if priorFailures == 0 {
payload["stopReason"] = "sender_resolution_failed"
}
return rawItems
return nil
}
filtered := make([]map[string]any, 0, len(rawItems))
unverifiableMessageIDs := make([]string, 0)
for _, item := range rawItems {
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
filtered = append(filtered, item)
if identity == "" || identity == "<nil>" {
messageID := chatmsg.StableMessageID(item)
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if filter.stableIDs[identity] {
filtered = append(filtered, item)
continue
}
if targetresolver.LooksLikeCurrentDOpenDingTalkID(identity) {
if !filter.hasOpenIDs {
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
}
} else if !filter.hasUserIDs {
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
}
}
if len(unverifiableMessageIDs) > 0 {
filter.scopeErr = apperrors.NewAPI(
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
apperrors.WithReason("chat_messages_sender_scope_unverified"),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
apperrors.WithDetails(map[string]any{
"requestedSenders": filter.inputs,
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
}),
)
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, map[string]any{
"stage": "sender_scope_verification",
"error": filter.scopeErr.Error(),
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
})
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(filtered) > 0
}
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
payload["count"] = len(filtered)
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
filterStatus := "applied"
if filter.scopeErr != nil {
filterStatus = "scope_unverified"
}
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": true,
"status": filterStatus,
}
identityResult := map[string]any{
"status": "evaluated",
"nameComparisonAllowed": false,
"negativeConclusionAllowed": true,
"stableIdField": "senderId",
}
if filter.scopeErr != nil {
identityResult["status"] = "scope_unverified"
identityResult["negativeConclusionAllowed"] = false
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
}
payload["identityResult"] = identityResult
return filtered
}
func chatMessagesMessageID(item map[string]any) string {
messageID := chatmsg.StableMessageID(item)
if messageID == "" {
return "<unknown>"
}
return messageID
}
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
if payload == nil || filter.requested {
return
}
payload["senderFilter"] = map[string]any{
"requested": false,
"applied": false,
"status": "not_requested",
}
payload["identityResult"] = map[string]any{
"status": "requires_query_check",
"negativeConclusionAllowed": false,
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
}
}
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
userID := rt.Str("user")
@@ -331,6 +495,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
)
}
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return chatMessagesRequest{}, err
}
}
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
if err != nil {
@@ -408,7 +577,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
return err
}
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter)
attachUnfilteredSenderIdentitySemantics(payload, senderFilter)
if err != nil {
// Full-page collection returns its failure ledger together with a
// non-zero error. Publish that ledger for diagnosis, but stop before
@@ -420,6 +590,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
}
return err
}
if senderFilter.requested && !senderFilter.applied {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.resolutionErr
}
if senderFilter.scopeErr != nil {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.scopeErr
}
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
+144 -1
View File
@@ -20,6 +20,7 @@ import (
stderrors "errors"
"math"
"os"
"reflect"
"testing"
"time"
@@ -546,7 +547,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender-query": "测试用户甲",
}))
if filter.applied || filter.failure == nil {
if filter.applied || filter.resolutionFailure == nil {
t.Fatalf("identity-free sender resolution = %#v", filter)
}
@@ -577,6 +578,148 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
}
}
func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"DAAAAAAAAAAAiE"},
inputMode: "sender",
stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true},
hasOpenIDs: true,
}
payload := map[string]any{
"complete": true,
"failures": []map[string]any{},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
&filter,
)
if len(filtered) != 0 || filter.scopeErr == nil || payload["complete"] != false {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) {
t.Run("direct resolution stops after first error", func(t *testing.T) {
fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender": testCurrentDOpenID + ",fixture-name,ignored-name",
}))
if filter.applied || filter.resolutionErr == nil || len(fake.calls) != 1 {
t.Fatalf("filter=%#v calls=%#v", filter, fake.calls)
}
})
t.Run("dedupes blanks and repeated values", func(t *testing.T) {
got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "})
if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("targets=%#v want=%#v", got, want)
}
})
t.Run("synthesizes failure when none was supplied", func(t *testing.T) {
payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}}
filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"}
if got := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter,
); got != nil {
t.Fatalf("filtered=%#v", got)
}
if _, exists := payload["stopReason"]; exists {
t.Fatalf("existing failure should retain stop reason ownership: %#v", payload)
}
})
t.Run("rejects missing and unmapped identity families", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"fixture"},
inputMode: "sender",
stableIDs: map[string]bool{"wanted-user": true},
hasUserIDs: true,
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{
{"content": "missing sender and id"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
&filter,
)
if len(filtered) != 1 || filter.scopeErr == nil || chatMessagesMessageID(map[string]any{}) != "<unknown>" || chatMessagesMessageID(filtered[0]) != "wanted" {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
})
t.Run("rejects user identity when only open ids were resolved", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{testCurrentDOpenID},
inputMode: "sender",
stableIDs: map[string]bool{testCurrentDOpenID: true},
hasOpenIDs: true,
}
applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{{"senderUserId": "other-user"}}, &filter,
)
if filter.scopeErr == nil {
t.Fatalf("filter=%#v", filter)
}
})
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
"open-dingtalk-id": "not-an-open-id",
})); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
}
func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) {
for _, tc := range []struct {
name string
sender string
}{
{name: "resolution", sender: "fixture-name"},
{name: "scope", sender: testCurrentDOpenID},
} {
t.Run(tc.name+" returns domain error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil {
t.Fatal("sender failure unexpectedly succeeded")
}
})
t.Run(tc.name+" prioritizes output error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t
want []string
}{
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "测试用户甲", "--page-all",
"--sender", "测试用户甲", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) {
fake := &platformCoverageCaller{
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"},
{"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender", "DAAAAAAAAAAAiE", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != true {
t.Fatalf("payload=%#v", payload)
}
}
func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) {
fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"},
{"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"}
]}}`}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("calls=%#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
identity := payload["identityResult"].(map[string]any)
if payload["count"] != float64(2) || identity["status"] != "requires_query_check" ||
identity["negativeConclusionAllowed"] != false {
t.Fatalf("payload=%#v", payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
@@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "不存在的人", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatalf("optional sender failure stopped message read: %v", err)
if err := root.Execute(); err == nil {
t.Fatal("sender resolution failure unexpectedly succeeded")
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["count"] != float64(1) || payload["complete"] != false ||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
if payload["count"] != float64(0) || payload["complete"] != false ||
payload["partial"] != false || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "sender_resolution_failed" {
t.Fatalf("unfiltered fallback payload = %#v", payload)
t.Fatalf("fail-closed payload = %#v", payload)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 0 {
t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"])
}
if _, exists := payload["resolvedFilters"]; exists {
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
+234 -30
View File
@@ -35,7 +35,7 @@ import (
// search hits through list_messages_by_ids in chunks of 50. A later-page or
// enrichment failure never turns a partial result into a false success: the
// output carries an explicit failure ledger and complete=false.
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId,并统一唯一解析为稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
@@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{
UseWhen: []string{searchMsgIntent},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
},
},
},
@@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
{Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "group", Type: shortcut.FlagString, Desc: "单个群名或 openConversationId;自动唯一解析并校验"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;可混合输入并逐项唯一解析"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
@@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
@@ -270,6 +271,13 @@ var SearchMsg = shortcut.Shortcut{
}
messages = validatedMessages
}
if len(resolvedFilters.Senders) > 0 {
var unverifiableMessageIDs []string
messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders)
if len(unverifiableMessageIDs) > 0 {
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
}
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
@@ -293,9 +301,19 @@ var SearchMsg = shortcut.Shortcut{
"failedCount": len(failures),
"failures": failures,
"queryRange": searchMessageQueryRange(params, order),
"timeCoverage": searchMessageTimeCoverage(rt),
"conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)),
}
if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
payload["senderScope"] = map[string]any{
"targetsResolved": true,
"filterApplied": true,
"filterMode": "server_and_client",
"resultsWithinScope": true,
}
}
if scopedSearch {
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
@@ -313,6 +331,39 @@ var SearchMsg = shortcut.Shortcut{
},
}
func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any {
source := "implicit_default"
if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") {
source = "explicit_range"
} else if rt.Changed("days") {
source = "explicit_days"
}
coverage := map[string]any{
"source": source,
"allHistory": false,
}
if source != "explicit_range" {
coverage["days"] = rt.Int("days")
}
return coverage
}
func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any {
coverage := searchMessageTimeCoverage(rt)
guard := map[string]any{
"absenceWithinQueryRangeAllowed": complete,
"absenceAcrossAllHistoryAllowed": false,
"allHistoryCountAllowed": false,
"requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger",
}
if coverage["source"] == "implicit_default" && resultCount == 0 {
guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger"
guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。"
}
return guard
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
@@ -350,6 +401,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
}
if err := validateSearchMsgStrictConversationAliases(rt); err != nil {
return err
}
return nil
}
// The conventional --group/--groups flags are intentionally dual-form. Only
// the explicit ID aliases remain strict so scripts that select them retain a
// deterministic contract.
func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error {
values := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
values = append(values, value)
}
for _, value := range uniqueSearchStrings(values) {
if strings.HasPrefix(strings.ToLower(value), "cid") {
continue
}
return apperrors.NewValidation(
fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)),
apperrors.WithDetails(map[string]any{
"flags": []string{"chat-id", "conversation-id", "id"},
"expectedType": "openConversationId",
"providedValue": value,
}),
)
}
return nil
}
@@ -358,6 +442,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
// the directory name, so consumers must join the selected stable identity to
// each projected message's senderId instead of comparing those two names.
type searchResolvedFilters struct {
Chats []targetresolver.ChatResolution `json:"chats,omitempty"`
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
}
@@ -367,41 +452,61 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
params["keyword"] = value
}
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
conversationIDs := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
conversationIDs = append(conversationIDs, value)
}
groupTargets := append([]string{}, rt.StrSlice("groups")...)
if value := rt.Str("group"); value != "" {
groupTargets = append(groupTargets, value)
}
for _, target := range uniqueSearchStrings(groupTargets) {
if strings.HasPrefix(strings.ToLower(target), "cid") {
conversationIDs = append(conversationIDs, target)
continue
}
resolved, err := targetresolver.ResolveChatTarget(rt, target, "")
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
for _, query := range queries {
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
}
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
params["openConversationIds"] = values
}
senders := append([]string{}, rt.StrSlice("senders")...)
senders = append(senders, rt.StrSlice("sender")...)
senderTargets := append([]string{}, rt.StrSlice("senders")...)
senderTargets = append(senderTargets, rt.StrSlice("sender")...)
for _, target := range uniqueSearchStrings(senderTargets) {
resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolved)
}
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
for _, resolved := range resolvedUsers {
identity := resolved.Selected.OpenDingTalkID
if identity == "" {
identity = resolved.Selected.UserID
}
senders = append(senders, identity)
}
}
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds")
atUsers, err := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids"))
if err != nil {
return nil, searchResolvedFilters{}, err
}
appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds")
if rt.Bool("at-me") || rt.Bool("is-at-me") {
params["atMe"] = true
}
@@ -439,15 +544,41 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
return params, resolvedFilters, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
var userIDs, openIDs []string
func resolveSearchStableActorTargets(
rt *shortcut.RuntimeContext,
values []string,
) ([]targetresolver.UserResolution, error) {
resolvedUsers := make([]targetresolver.UserResolution, 0, len(values))
for _, value := range uniqueSearchStrings(values) {
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
openIDs = append(openIDs, value)
} else {
userIDs = append(userIDs, value)
resolved, err := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny)
if err != nil {
return nil, err
}
resolvedUsers = append(resolvedUsers, resolved)
}
return resolvedUsers, nil
}
// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory
// resolution supplies it because the current message-search backend reliably
// applies the open-ID field but may ignore senderUserIds.
func appendResolvedSearchActorIDs(
params map[string]any,
resolutions []targetresolver.UserResolution,
userKey, openIDKey string,
) {
var userIDs, openIDs []string
for _, resolved := range resolutions {
openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID)
userID := strings.TrimSpace(resolved.Selected.UserID)
if openID != "" {
openIDs = append(openIDs, openID)
} else if userID != "" {
userIDs = append(userIDs, userID)
}
}
userIDs = uniqueSearchStrings(userIDs)
openIDs = uniqueSearchStrings(openIDs)
if len(userIDs) > 0 {
params[userKey] = userIDs
}
@@ -527,6 +658,79 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin
}
}
func filterSearchSenderScope(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) ([]map[string]any, []string) {
allowed := map[string]bool{}
hasUserIDs := false
hasOpenIDs := false
for _, resolution := range resolutions {
if value := strings.TrimSpace(resolution.Selected.UserID); value != "" {
allowed[value] = true
hasUserIDs = true
}
if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" {
allowed[value] = true
hasOpenIDs = true
}
}
filtered := make([]map[string]any, 0, len(messages))
unverifiable := make([]string, 0)
for _, message := range messages {
senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message)))
if senderID == "" || senderID == "<nil>" {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID == "" || messageID == "<nil>" {
messageID = "<unknown>"
}
unverifiable = append(unverifiable, messageID)
continue
}
if allowed[senderID] {
filtered = append(filtered, message)
continue
}
if targetresolver.LooksLikeCurrentDOpenDingTalkID(senderID) {
if !hasOpenIDs {
unverifiable = append(unverifiable, searchScopeMessageID(message))
}
} else if !hasUserIDs {
unverifiable = append(unverifiable, searchScopeMessageID(message))
}
}
return filtered, uniqueSearchStrings(unverifiable)
}
func searchScopeMessageID(message map[string]any) string {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID == "" || messageID == "<nil>" {
return "<unknown>"
}
return messageID
}
func searchSenderScopeUnverifiedError(
resolutions []targetresolver.UserResolution,
messageIDs []string,
) error {
queries := make([]string, 0, len(resolutions))
for _, resolution := range resolutions {
queries = append(queries, resolution.Query)
}
return apperrors.NewAPI(
"搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出",
apperrors.WithReason("search_sender_scope_unverified"),
apperrors.WithDetails(map[string]any{
"requestedSenders": uniqueSearchStrings(queries),
"unverifiableMessageIds": messageIDs,
}),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"),
)
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
@@ -15,25 +15,35 @@ 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/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type searchMsgExecutionCaller struct {
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
contactResponse string
groupResponse string
failContactKeyword string
failGroupKeyword string
}
const (
testCurrentDOpenID = "DAAAAAAAAAAAiE"
testCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
if product == "chat" && tool == "get_conversation_info" {
@@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
}
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
}
if product == "contact" && tool == "search_contact_by_key_word" {
if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword {
return nil, errors.New("fixture contact failure")
}
if f.contactResponse != "" {
return searchMsgToolResult(f.contactResponse), nil
}
keyword := args["keyword"].(string)
return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil
}
if product != "im" {
return nil, errors.New("unexpected product")
}
switch tool {
case "search_groups":
if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword {
return nil, errors.New("fixture group failure")
}
if f.groupResponse != "" {
return searchMsgToolResult(f.groupResponse), nil
}
return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil
case "search_messages":
if f.searchResponse != "" {
return searchMsgToolResult(f.searchResponse), nil
@@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.failSecondPage {
return nil, errors.New("second page unavailable")
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
return nil, errors.New("mget unavailable")
@@ -129,13 +157,175 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m
return payload, nil
}
func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) {
tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"}
for _, target := range tests {
t.Run(target, func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich")
if payload["complete"] != true || len(caller.calls) != 2 {
t.Fatalf("payload=%#v calls=%#v", payload, caller.calls)
}
if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" ||
caller.calls[1].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) {
t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want)
}
})
}
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls)
}
}
func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
{"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"}
],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if payload["count"] != float64(1) {
t.Fatalf("payload=%#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "wanted" {
t.Fatalf("messages=%#v", messages)
}
}
func TestCrossPlatformCoverageSearchMsgSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
[]targetresolver.UserResolution{{
Selected: targetresolver.User{OpenDingTalkID: testCurrentDOpenID},
}},
)
if len(filtered) != 0 || !reflect.DeepEqual(unverifiable, []string{"m-user-family"}) {
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
}
func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) {
for _, tc := range []struct {
name string
caller *searchMsgExecutionCaller
args []string
}{
{
name: "group target",
caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"},
args: []string{"--group", "missing-group", "--no-enrich"},
},
{
name: "sender target",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-sender"},
args: []string{"--sender", "missing-sender", "--no-enrich"},
},
{
name: "sender query",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"},
args: []string{"--sender-query", "missing-query", "--no-enrich"},
},
{
name: "at stable id",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-at"},
args: []string{"--at-ids", "missing-at", "--no-enrich"},
},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil {
t.Fatal("resolution failure unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) {
t.Run("execute rejects senderless backend rows", func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`}
_, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
})
resolutions := []targetresolver.UserResolution{{
Query: "fixture-user",
Selected: targetresolver.User{UserID: "wanted-user"},
}}
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{
{"content": "missing identity"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
resolutions,
)
if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"<unknown>", "open-family"}) {
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
if got := searchScopeMessageID(map[string]any{}); got != "<unknown>" {
t.Fatalf("missing message ID=%q", got)
}
if got := searchScopeMessageID(map[string]any{"openMessageId": "message-1"}); got != "message-1" {
t.Fatalf("message ID=%q", got)
}
err := searchSenderScopeUnverifiedError(
append(resolutions, resolutions...),
[]string{"message-1"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich")
coverage := payload["timeCoverage"].(map[string]any)
if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) {
t.Fatalf("coverage=%#v", coverage)
}
}
func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) {
caller := &searchMsgExecutionCaller{}
_, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich")
if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" ||
caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
coverage := payload["timeCoverage"].(map[string]any)
guard := payload["conclusionGuard"].(map[string]any)
if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) ||
guard["absenceAcrossAllHistoryAllowed"] != false {
t.Fatalf("coverage=%#v guard=%#v", coverage, guard)
}
}
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--chat-id", "cid-1,cid-2",
"--sender", "42,Dsender",
"--at-ids", "43,Dat",
"--sender", testCurrentDOpenID,
"--at-ids", testCurrentDOpenID2,
"--is-at-me",
"--message-type", "text",
"--only-robot",
@@ -160,10 +350,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
t.Fatalf("first call = %#v", first)
}
for key, want := range map[string]any{
"senderUserIds": []string{"42"},
"senderOpenDingTakIds": []string{"Dsender"},
"atUserIds": []string{"43"},
"atOpenDingTakIds": []string{"Dat"},
"senderOpenDingTakIds": []string{testCurrentDOpenID},
"atOpenDingTakIds": []string{testCurrentDOpenID2},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
@@ -9,6 +9,7 @@
package targetresolver
import (
"encoding/base64"
stderrors "errors"
"fmt"
"strings"
@@ -105,6 +106,274 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User
return resolveUserCandidates(ExtractUsers(data), query, requirement)
}
// ResolveUserTarget accepts the mixed user target used by public convenience
// flags: a natural name, userId, or current-version openDingTalkId. The local
// format check is deliberately a classifier rather than an existence check.
// Format-valid IDs go directly to the downstream business API; all other
// values use the directory resolver, which can match names and exact userIds.
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定用户姓名、userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if !LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUser(rt, value, requirement)
}
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
// ResolveSenderTarget resolves the mixed identity accepted specifically by
// sender convenience flags. Unlike the shared name resolver, it may select a
// unique candidate whose stable userId/openDingTalkId exactly equals the
// supplied value. Natural-name candidates retain the shared fail-closed
// ambiguity behavior.
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUserTarget(rt, value, requirement)
}
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": value,
})
if err != nil {
return UserResolution{}, err
}
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
stableMatches := make([]User, 0, 1)
for _, user := range users {
if strings.TrimSpace(user.UserID) == value ||
strings.TrimSpace(user.OpenDingTalkID) == value {
stableMatches = append(stableMatches, user)
}
}
if len(stableMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: stableMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(stableMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
}
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
}
return resolveUserCandidates(users, value, requirement)
}
// ResolveStableUserTarget accepts only an exact userId or current-version
// openDingTalkId. It is intended for flags whose contract explicitly excludes
// natural names; no unique-name fallback is allowed.
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定 userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if LooksLikeCurrentDOpenDingTalkID(value) {
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": value,
})
if err != nil {
return UserResolution{}, err
}
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
stableMatches := make([]User, 0, 1)
for _, user := range users {
if strings.TrimSpace(user.UserID) == value ||
strings.TrimSpace(user.OpenDingTalkID) == value {
stableMatches = append(stableMatches, user)
}
}
if len(stableMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: stableMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(stableMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
}
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
}
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("%q 未精确匹配到 userId 或 openDingTalkId", value),
apperrors.WithReason("stable_user_identity_required"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint("请先解析目标人员,并传入通讯录结果中原样返回的 userId 或 openDingTalkId"),
apperrors.WithDetails(map[string]any{
"providedValue": value,
"expectedTypes": []string{"userId", "openDingTalkId"},
}),
)
}
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
// wire format emitted by the current D-version encoder. It does not decrypt
// the value and therefore does not prove that the identity exists or is
// accessible to the current profile.
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 2 || value[0] != 'D' {
return false
}
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
if !ok {
return false
}
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
return false
}
// Require the canonical Base64 and escape spelling. This rejects alternate
// but decodable strings and keeps the classifier aligned with the encoder.
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
return canonical == value[1:]
}
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
var decoded strings.Builder
decoded.Grow(len(value))
for i := 0; i < len(value); i++ {
current := value[i]
if current != 'i' {
if !isASCIIAlphaNumeric(current) {
return "", false
}
decoded.WriteByte(current)
continue
}
if i+1 >= len(value) {
return "", false
}
i++
switch value[i] {
case 'i':
decoded.WriteByte('i')
case 'P':
decoded.WriteByte('+')
case 'S':
decoded.WriteByte('/')
case 'E':
decoded.WriteByte('=')
default:
return "", false
}
}
return decoded.String(), true
}
func escapeCurrentDOpenDingTalkID(value string) string {
var encoded strings.Builder
encoded.Grow(len(value))
for i := 0; i < len(value); i++ {
switch value[i] {
case 'i':
encoded.WriteString("ii")
case '+':
encoded.WriteString("iP")
case '/':
encoded.WriteString("iS")
case '=':
encoded.WriteString("iE")
default:
encoded.WriteByte(value[i])
}
}
return encoded.String()
}
func isASCIIAlphaNumeric(value byte) bool {
return value >= '0' && value <= '9' ||
value >= 'A' && value <= 'Z' ||
value >= 'a' && value <= 'z'
}
// ValidateExplicitOpenDingTalkID applies the current-version format contract
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
// must fail locally instead of falling back to a name or userId lookup.
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
apperrors.WithDetails(map[string]any{
"flag": flagName,
"expectedType": "current_d_openDingTalkId",
}),
)
}
// ResolveEnterpriseUser resolves an organization member through the same
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
// contact search, this source understands organization nicknames and aliases.
@@ -686,3 +686,278 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
}
})
}
func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true},
{name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true},
{name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true},
{name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true},
{name: "empty", value: "", want: false},
{name: "prefix only", value: "D", want: false},
{name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false},
{name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false},
{name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false},
{name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false},
{name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false},
{name: "invalid base64", value: "Dabc", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want {
t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want)
}
})
}
}
func TestResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) {
calls := 0
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
calls++
if product != "contact" || tool != "search_contact_by_key_word" {
t.Fatalf("unexpected tool %s/%s", product, tool)
}
query := fmt.Sprint(params["keyword"])
switch query {
case "D-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user",
}}}, nil
case "d-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user",
}}}, nil
case "fixture-user-id":
return map[string]any{"result": []any{
map[string]any{"userId": "other", "name": "相似候选"},
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"},
}}, nil
default:
t.Fatalf("unexpected query %q", query)
return nil, nil
}
})
for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} {
_, err := ResolveSenderTarget(reader, query, IdentityAny)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
}
_, err := ResolveUser(reader, "fixture-user-id", IdentityAny)
var ambiguous *apperrors.Error
if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" {
t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err)
}
resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact sender userId resolution = %#v", resolved)
}
if calls != 4 {
t.Fatalf("calls=%d", calls)
}
}
func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) {
if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil {
t.Fatalf("valid format: %v", err)
}
err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user")
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ")
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("whitespace-wrapped explicit ID error=%#v", err)
}
}
func TestCrossPlatformCoverageResolveStableUserTargetRejectsNaturalNameButAcceptsExactUserID(t *testing.T) {
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
if product != "contact" || tool != "search_contact_by_key_word" {
t.Fatalf("unexpected tool %s/%s", product, tool)
}
return map[string]any{"result": []any{
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试用户甲"},
}}, nil
})
resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact userId resolution = %#v", resolved)
}
_, err = ResolveStableUserTarget(reader, "测试用户甲", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "stable_user_identity_required" {
t.Fatalf("natural name error = %#v", err)
}
}
func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) {
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"},
map[string]any{"userId": "other", "name": "其他候选"},
},
"hasMore": true,
}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable-only", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name, func(t *testing.T) {
resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("resolved=%#v", resolved)
}
})
}
}
func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {
unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return nil, stderrors.New("directory unavailable")
})
for _, tc := range []struct {
name string
resolve func(Reader, string, IdentityRequirement) (UserResolution, error)
value string
requirement IdentityRequirement
wantReason string
}{
{name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"},
{name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
{name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"},
{name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.resolve(unexpectedReader, tc.value, tc.requirement)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason {
t.Fatalf("error=%#v, want reason %q", err, tc.wantReason)
}
})
}
if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("mixed directory error=%v", err)
}
if _, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("sender directory error=%v", err)
}
if _, err := ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("stable directory error=%v", err)
}
}
func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) {
duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{"result": []any{
map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"},
map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"},
}}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name+" ambiguous", func(t *testing.T) {
_, err := resolve.fn(duplicateReader, "duplicate", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("error=%#v", err)
}
})
}
incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{map[string]any{"userId": "other", "name": "其他"}},
"hasMore": true,
}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name+" incomplete", func(t *testing.T) {
_, err := resolve.fn(incompleteReader, "missing", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error=%#v", err)
}
})
}
}
func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) {
if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok {
t.Fatal("dangling escape unexpectedly accepted")
}
if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" {
t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok)
}
if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" {
t.Fatalf("escaped=%q", got)
}
}
+10 -9
View File
@@ -33,6 +33,7 @@ import (
const (
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
)
type recordedToolCall struct {
@@ -293,7 +294,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
stdout, stderr, err := runCLI(t, env,
"--token", "ci-smoke-token", "--format", "json",
"chat", "+messages-send", "--as", "user",
"--user-query", "张三", "--text", "你好", "--yes",
"--user-query", "测试用户甲", "--text", "你好", "--yes",
)
if err != nil {
t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
@@ -302,7 +303,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" {
t.Fatalf("calls = %#v, want resolve + one send", calls)
}
if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" {
if calls[1].arguments["receiverOpenDingTalkId"] != mockCurrentDOpenID {
t.Fatalf("send target = %#v", calls[1].arguments)
}
assertMultiIMCallsAreLocalAndAuthorized(t, calls)
@@ -473,7 +474,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
t.Fatalf("messages = %#v", payload["messages"])
}
message, _ := messages[0].(map[string]any)
if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" {
if message["messageId"] != "msg-1" || message["senderId"] != mockCurrentDOpenID || message["senderType"] != "user" {
t.Fatalf("projected message = %#v", message)
}
})
@@ -483,7 +484,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
stdout, stderr, err := runCLI(t, env,
"--token", "ci-smoke-token", "--format", "json",
"chat", "+chat-create", "--name", "新群",
"--member-query", "张三", "--dry-run", "--yes",
"--member-query", "测试用户甲", "--dry-run", "--yes",
)
if err != nil {
t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
@@ -505,7 +506,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
"--token", "ci-smoke-token", "--format", "json",
"chat", "+messages-reply",
"--conversation-id", "cid-1", "--message-id", "msg-1",
"--ref-sender", "D-sender", "--text", "收到",
"--ref-sender", mockCurrentDOpenID, "--text", "收到",
"--idempotency-key", "reply-key", "--yes",
)
if err != nil {
@@ -532,7 +533,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
if arguments["keyword"] == "同名用户" {
return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}`
}
return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}`
return `{"result":[{"name":"测试用户甲","userId":"user-1","openDingTalkId":"` + mockCurrentDOpenID + `"}]}`
case "search_groups":
if arguments["keyword"] == "资源群" {
return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}`
@@ -540,9 +541,9 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}`
case "list_conversation_message_v2":
if arguments["openconversation_id"] == "cid-resources" {
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
}
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
case "get_resource_download_url":
body, _ := json.Marshal(map[string]any{"result": map[string]any{
"resourceUrl": resourceURL,
@@ -561,7 +562,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" {
return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}`
}
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"` + mockCurrentDOpenID + `","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
case "get_current_user_profile":
return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "create_group_conversation":