From a5111f486bff48d8fb1cfbe82c7e596225031850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=A9=E6=9C=9D?= Date: Thu, 13 Aug 2026 16:13:28 +0800 Subject: [PATCH] fix(chat): harden openDingTalkId target routing --- .changes/open-dingtalk-id-format-routing.md | 5 + internal/app/param_alias_e2e_test.go | 8 +- .../param_alias_payload_equivalence_test.go | 27 +- internal/helpers/chat.go | 30 +- internal/helpers/chat_coverage_more_test.go | 29 +- internal/helpers/chat_file_path_send_test.go | 8 +- internal/helpers/chat_message_search_test.go | 52 ++-- internal/shortcut/chat/chat.go | 21 +- internal/shortcut/chat/chat_conversation.go | 4 + internal/shortcut/chat/chat_group.go | 3 + internal/shortcut/chat/chat_message.go | 18 ++ .../chat/compatibility_coverage_test.go | 23 +- .../shortcut/chat/coverage_completion_test.go | 32 +- internal/shortcut/chat/gap_fill_test.go | 69 +++-- internal/shortcut/chat/lark_alignment.go | 9 +- internal/shortcut/chat/lark_alignment_test.go | 10 +- .../chat/remaining_read_pagination_test.go | 2 +- internal/shortcut/chat/unified_send.go | 11 + internal/shortcut/smart/chat_messages.go | 270 ++++++++++++++--- internal/shortcut/smart/chat_messages_test.go | 145 ++++++++- .../smart/coverage_completion_test.go | 2 +- .../smart/natural_target_resolution_test.go | 79 ++++- internal/shortcut/smart/search_msg.go | 264 +++++++++++++++-- .../smart/search_msg_execution_test.go | 230 +++++++++++++-- internal/shortcut/targetresolver/resolver.go | 269 +++++++++++++++++ .../shortcut/targetresolver/resolver_test.go | 275 ++++++++++++++++++ test/mock_mcp/mock_mcp_smoke_test.go | 19 +- 27 files changed, 1691 insertions(+), 223 deletions(-) create mode 100644 .changes/open-dingtalk-id-format-routing.md diff --git a/.changes/open-dingtalk-id-format-routing.md b/.changes/open-dingtalk-id-format-routing.md new file mode 100644 index 00000000..10f6f191 --- /dev/null +++ b/.changes/open-dingtalk-id-format-routing.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts. diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 25581789..a3eeaf51 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -361,7 +361,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) { caller := ¶mAliasCaptureCaller{} ctx, err := executeParamAliasE2E(t, caller, "chat", "message", "send", - "--to-user", "D-recipient", + "--to-user", appFixtureCurrentDOpenID, "--text", "hello alias", "--idempotency-key", "alias-e2e", ) @@ -375,7 +375,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) @@ -689,11 +689,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"}, diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 5d2637c8..ef191e85 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -14,6 +14,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 @@ -34,12 +39,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"}, @@ -57,7 +62,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"}, @@ -70,10 +75,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"}, @@ -88,9 +93,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", "--idempotency-key", "param-alias-equivalence", "--yes"}, + "chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "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"}, @@ -211,7 +216,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", "--idempotency-key", "param-alias-equivalence-group", "--yes"}, diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index f36dd104..bba9e26c 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -520,6 +520,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 + } + } startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err @@ -1051,8 +1056,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 的响应,判定是否发送失败。 @@ -2692,6 +2696,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 = "" @@ -2784,6 +2793,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 = "" @@ -2913,6 +2927,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 = "" @@ -4726,9 +4745,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 diff --git a/internal/helpers/chat_coverage_more_test.go b/internal/helpers/chat_coverage_more_test.go index 55510c88..3c9f1ad0 100644 --- a/internal/helpers/chat_coverage_more_test.go +++ b/internal/helpers/chat_coverage_more_test.go @@ -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: `{}`}} diff --git a/internal/helpers/chat_file_path_send_test.go b/internal/helpers/chat_file_path_send_test.go index 005940e7..b828a35d 100644 --- a/internal/helpers/chat_file_path_send_test.go +++ b/internal/helpers/chat_file_path_send_test.go @@ -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 { diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index bdf1da88..c0bcc370 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -879,11 +879,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"}, }, } @@ -912,11 +912,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"}, }, } @@ -950,25 +950,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", @@ -976,15 +976,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", @@ -992,13 +992,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", @@ -1006,15 +1006,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", @@ -1022,7 +1022,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", }, @@ -1036,7 +1036,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", diff --git a/internal/shortcut/chat/chat.go b/internal/shortcut/chat/chat.go index 148a7f38..4a0ed222 100644 --- a/internal/shortcut/chat/chat.go +++ b/internal/shortcut/chat/chat.go @@ -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)) diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index a575f9a2..a5e83752 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -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 { diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index d9b5d31c..a959874e 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{ }, Tips: []string{`dws chat +chat-members-get --id --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"), diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 536686e6..553b604c 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -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 --group --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 --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 --at-open-dingtalk-ids --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 之间") } diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index 82b3defd..43d94d8d 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -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) } } diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 98d5cb1a..0bdbe1ab 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -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) } diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index ad2d428f..7cd22e11 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -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{ diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index f8a7a605..a9f4c4e0 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -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 != "" { 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 != "" { return candidate } } diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index 5dbf9f19..cea41825 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -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", diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go index d5506dbc..b3e1ea77 100644 --- a/internal/shortcut/chat/remaining_read_pagination_test.go +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -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", }) diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 9991d147..1977d077 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -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 diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index ed03dd4c..83c94868 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -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 != "" && filter.stableIDs[identity] { - filtered = append(filtered, item) + if identity == "" || identity == "" { + messageID := chatmsg.StableMessageID(item) + if messageID == "" { + messageID = "" + } + 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 "" + } + 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, diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 0c3918c9..66ca0679 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -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{}) != "" || 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() diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index 4503d79e..61fbe8bc 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -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) } diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index df5bc3ae..f6704eb9 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -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) diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 48b15ec7..2224d542 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -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 --days 3 --page-all", - "dws chat +search-msg --query \"周报\" --senders --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 --query "changefree"`, `dws chat +search-msg --senders --at-me --days 3 --page-all`, `dws chat +search-msg --group --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 == "" { + messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) + if messageID == "" || messageID == "" { + messageID = "" + } + 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 == "" { + return "" + } + 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) diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index ec83c2ff..161abe4e 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -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{"", "open-family"}) { + t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable) + } + if got := searchScopeMessageID(map[string]any{}); got != "" { + 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", diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index f4cf72cd..7dc165b1 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -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. diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index 4e81b49d..2da5e817 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -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) + } +} diff --git a/test/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go index 58e5127b..d84e704f 100644 --- a/test/mock_mcp/mock_mcp_smoke_test.go +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -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":