diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df160be..86b192c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ and compatibility and CI reliability fixes. ### Changed +- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders. - **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing. - **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger. - **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 9d8700a9..268b6b8e 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -404,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) return text } +// applyCurrentUserGroupMentions keeps the body placeholders and +// send_personal_message mention arguments aligned for send and reply. +func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string { + var atOpenIDs []string + if rawOpenIDs != "" { + atOpenIDs = strings.Split(rawOpenIDs, ",") + } + if atAll && !strings.Contains(text, "<@all>") { + text = "<@all> " + text + } + text = normalizeAtPlaceholders(text, atOpenIDs, true) + if atAll { + params["atAll"] = true + } + if len(atOpenIDs) > 0 { + params["atOpenDingTalkIds"] = atOpenIDs + } + return text +} + +func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string { + if rawOpenIDs == "" { + return text + } + missing := make([]string, 0) + probeText := text + for _, id := range parseCSVValues(rawOpenIDs) { + placeholder := "<@" + id + ">" + if strings.Contains(probeText, placeholder) { + continue + } + missing = append(missing, placeholder) + probeText += placeholder + } + if len(missing) == 0 { + return text + } + prefix := strings.Join(missing, " ") + if strings.HasPrefix(text, "<@all> ") { + return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ") + } + return prefix + " " + text +} + func containsMessageMention(text, placeholder string) bool { if strings.HasPrefix(placeholder, "<") { return strings.Contains(text, placeholder) @@ -2094,29 +2138,15 @@ func newChatCommand() *cobra.Command { if groupID != "" { atAll, _ := cmd.Flags().GetBool("at-all") atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids") - var atOpenIds []string - if atOpenIdsStr != "" { - atOpenIds = strings.Split(atOpenIdsStr, ",") - } - if atAll && !strings.Contains(text, "<@all>") { - text = "<@all> " + text - } - // 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变 - text = normalizeAtPlaceholders(text, atOpenIds, true) // 群聊统一走 openDingTalkId @ 人接口。 - contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text}) newParams := map[string]any{ "openConversationId": groupID, "msgType": "markdown", - "content": string(contentJSON), "clawType": clawType, } - if atAll { - newParams["atAll"] = true - } - if len(atOpenIds) > 0 { - newParams["atOpenDingTalkIds"] = atOpenIds - } + text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll) + contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text}) + newParams["content"] = string(contentJSON) if msgUuid != "" { newParams["uuid"] = msgUuid } @@ -5508,13 +5538,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatMessageReplyCmd := &cobra.Command{ Use: "reply", Short: "引用回复消息(支持单聊/群聊)", - Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。 + Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`, - Example: ` dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理"`, + Example: ` dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理" + dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请看一下" --at-open-dingtalk-ids `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil { return err @@ -5527,13 +5558,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } refSender = resolved } - replyContent := map[string]string{ - "referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"), - "srcMsgSendOpenDingTalkId": refSender, - "replyMsgType": "text", - "content": mustGetFlag(cmd, "text"), - } - contentJSON, _ := marshalJSONRaw(replyContent) clawType := "" aiTag, _ := cmd.Flags().GetBool("ai-tag") if aiTag { @@ -5542,9 +5566,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "conversation-id"), "msgType": "reply", - "content": string(contentJSON), "clawType": clawType, } + atAll, _ := cmd.Flags().GetBool("at-all") + atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids") + replyText := applyCurrentUserGroupMentions( + toolArgs, + mustGetFlag(cmd, "text"), + atOpenIDs, + atAll, + ) + replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs) + replyContent := map[string]string{ + "referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"), + "srcMsgSendOpenDingTalkId": refSender, + "replyMsgType": "text", + "content": replyText, + } + contentJSON, _ := marshalJSONRaw(replyContent) + toolArgs["content"] = string(contentJSON) if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v } @@ -5578,6 +5618,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Parameters: []contract.ParamDecl{ {Name: "ai-tag", Property: "clawType", InterfaceType: "string"}, + {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, + {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"}, {Name: "conversation-id", Property: "openConversationId"}, }, }, @@ -5592,6 +5634,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 _ = chatMessageReplyCmd.MarkFlagRequired("text") chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)") chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)") + chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)") + chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)") cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat") // ── message forward: 转发单条消息 ──────────────────────── diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 91fcd22a..3d559b51 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -15,6 +15,7 @@ package helpers import ( "context" + "encoding/json" "io" "os" "reflect" @@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) { } } +func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) { + tests := []struct { + name string + args []string + contentField string + wantContent string + wantAtAll bool + wantOpenIDs []string + }{ + { + name: "send", + args: []string{ + "message", "send", "--group", "cid", + "--text", "收到 @D-target 和 <@D-second>", + "--at-open-dingtalk-ids", "D-target,D-second", + "--at-all", + }, + contentField: "text", + wantContent: "<@all> 收到 <@D-target> 和 <@D-second>", + wantAtAll: true, + wantOpenIDs: []string{"D-target", "D-second"}, + }, + { + name: "send keeps missing member placeholders unchanged", + args: []string{ + "message", "send", "--group", "cid", + "--text", "DWS 发消息自测", + "--at-open-dingtalk-ids", "D-target", + }, + contentField: "text", + wantContent: "DWS 发消息自测", + wantOpenIDs: []string{"D-target"}, + }, + { + name: "reply", + args: []string{ + "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", + "--at-all", + }, + contentField: "content", + wantContent: "<@all> 收到 <@D-target> 和 <@D-second>", + wantAtAll: true, + wantOpenIDs: []string{"D-target", "D-second"}, + }, + { + name: "reply adds missing member placeholders", + args: []string{ + "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", + }, + contentField: "content", + wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测", + wantOpenIDs: []string{"D-target", "D-second", "D-target"}, + }, + { + name: "reply adds missing member placeholders after at-all", + args: []string{ + "message", "reply", + "--conversation-id", "cid", + "--ref-msg-id", "mid", + "--ref-sender", "D-sender", + "--text", "请大家确认", + "--at-open-dingtalk-ids", "D-target", + "--at-all", + }, + contentField: "content", + wantContent: "<@all> <@D-target> 请大家确认", + wantAtAll: true, + wantOpenIDs: []string{"D-target"}, + }, + { + name: "reply at-all preserves alliance word", + args: []string{ + "message", "reply", + "--conversation-id", "cid", + "--ref-msg-id", "mid", + "--ref-sender", "D-sender", + "--text", "联系 @alliance", + "--at-all", + }, + contentField: "content", + wantContent: "<@all> 联系 @alliance", + wantAtAll: true, + }, + { + name: "reply without at flags preserves alliance word", + args: []string{ + "message", "reply", + "--conversation-id", "cid", + "--ref-msg-id", "mid", + "--ref-sender", "D-sender", + "--text", "联系 @alliance", + }, + contentField: "content", + wantContent: "联系 @alliance", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &chatChangedContractCaller{} + if err := executeChatChangedContract(t, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" { + t.Fatalf("calls = %#v", caller.calls) + } + args := caller.calls[0].args + gotAtAll, hasAtAll := args["atAll"] + if tc.wantAtAll { + if !hasAtAll || gotAtAll != true { + t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll) + } + } else if hasAtAll { + t.Fatalf("atAll = %#v; want absent", gotAtAll) + } + gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"] + if len(tc.wantOpenIDs) > 0 { + if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) { + t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs) + } + } else if hasOpenIDs { + t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs) + } + var content map[string]string + if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil { + t.Fatal(err) + } + if got := content[tc.contentField]; got != tc.wantContent { + t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent) + } + }) + } +} + func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) { caller := &chatChangedContractCaller{} err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello") diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 342ca05c..57a54f5a 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -1251,8 +1251,11 @@ Usage: dws chat message reply [flags] Example: dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理" + dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请看一下" --at-open-dingtalk-ids # 被引用消息的 openMessageId、发送者 openDingTalkId 通过 dws chat message list 获取 Flags: + --at-all @所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐) + --at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐) --conversation-id string 会话 openConversationId (必填,支持单聊/群聊) --ref-msg-id string 被引用的消息 openMessageId (必填) --ref-sender string 被引用消息的发送者 openDingTalkId (必填) @@ -1262,6 +1265,7 @@ Flags: 注意: - 以当前用户身份引用回复,语义同 chat message send;目前回复类型仅支持 text + - 群聊 @指定成员时,正文缺少对应 <@openDingTalkId> 会自动补齐,已有裸 @openDingTalkId 会规范化;--at-all 会自动补齐 <@all> ``` #### 转发单条消息 — 将一条消息从源会话转发到目标会话(源/目标均支持单聊/群聊) diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 2bcf550f..49949894 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -180,11 +180,18 @@ dws chat message edit --group --msg-id --co | 命令 | 用途 | 必填参数 | |------|------|----------| -| `message reply` | 引用回复,单聊/群聊均可 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text` | +| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` | | `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` | | `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` | | `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` | +群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。 + +```bash +dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请看一下" --at-open-dingtalk-ids +dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请大家确认" --at-all +``` + ### 话题与卡片 话题完整读取流程: