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