diff --git a/CHANGELOG.md b/CHANGELOG.md index adec5e1c..2b2dd499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **`dws chat message reply` @-mentions** (#359, `internal/helpers/chat.go`) — reply now accepts `--at-open-dingtalk-ids` and `--at-all`, forwarding them to `send_personal_message` as `atOpenDingTalkIds` / `isAtAll`. As with `chat message send`, the reply text must include matching `<@openDingTalkId>` / `<@all>` placeholders for DingTalk clients to render the mention. + ## [1.0.35] - 2026-06-08 ### Fixed diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index c1cdca3d..ba6b6cfd 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -940,7 +940,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "reply", Short: "引用回复消息(支持单聊/群聊)", - Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。", + Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。使用 --at-open-dingtalk-ids / --at-all 时,--text 需包含对应的 <@openDingTalkId> / <@all> 占位符,客户端才会渲染 @。", Example: ` dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理"`, Args: cobra.NoArgs, DisableAutoGenTag: true, @@ -980,6 +980,14 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" { params["uuid"] = uuid } + if v, _ := cmd.Flags().GetString("at-open-dingtalk-ids"); strings.TrimSpace(v) != "" { + if ids := splitCSVStrings(v); len(ids) > 0 { + params["atOpenDingTalkIds"] = ids + } + } + if v, _ := cmd.Flags().GetBool("at-all"); v { + params["isAtAll"] = true + } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "group-chat", @@ -999,16 +1007,20 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)") cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)") cmd.Flags().String("text", "", "回复正文 (必填)") + cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingTalkId 列表,逗号分隔;--text 需包含 <@openDingTalkId> 占位符") + cmd.Flags().Bool("at-all", false, "@所有人;--text 需包含 <@all> 占位符") cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)") return cmd } func jsonMarshal(v any) (string, error) { - b, err := json.Marshal(v) - if err != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { return "", err } - return string(b), nil + return strings.TrimRight(buf.String(), "\n"), nil } // marshalMessageContent builds the send_personal_message content payload diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index a2d2b95b..564fa92d 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -3,6 +3,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "strings" "testing" @@ -385,3 +386,153 @@ func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) { }) } } + +func TestChatMessageReplyForwardsAtMentions(t *testing.T) { + cases := []struct { + name string + extraArgs []string + wantParams map[string]any + wantAbsent []string + }{ + { + name: "at-open-dingtalk-ids", + extraArgs: []string{"--text", "<@op-1> <@op-2> 收到,马上处理", "--at-open-dingtalk-ids", "op-1, op-2"}, + wantParams: map[string]any{ + "atOpenDingTalkIds": []string{"op-1", "op-2"}, + }, + wantAbsent: []string{"atAll", "isAtAll"}, + }, + { + name: "at-all", + extraArgs: []string{"--text", "<@all> 收到,马上处理", "--at-all"}, + wantParams: map[string]any{ + "isAtAll": true, + }, + wantAbsent: []string{"atOpenDingTalkIds", "atAll"}, + }, + { + name: "without-at-flags", + extraArgs: []string{"--text", "收到,马上处理"}, + wantAbsent: []string{"atOpenDingTalkIds", "atAll", "isAtAll"}, + }, + { + name: "whitespace-at-open-dingtalk-ids", + extraArgs: []string{"--text", "收到,马上处理", "--at-open-dingtalk-ids", " "}, + wantAbsent: []string{"atOpenDingTalkIds", "atAll", "isAtAll"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := []string{ + "--conversation-id", "cid-xyz", + "--ref-msg-id", "msg-123", + "--ref-sender", "sender-op", + } + args = append(args, tc.extraArgs...) + runner, out, err := runChatMessageReply(t, args...) + if err != nil { + t.Fatalf("Execute() error = %v\noutput:\n%s", err, out) + } + if got := runner.last.Tool; got != "send_personal_message" { + t.Fatalf("Tool = %q, want send_personal_message", got) + } + if got := runner.last.CanonicalProduct; got != "group-chat" { + t.Fatalf("CanonicalProduct = %q, want group-chat", got) + } + for key, want := range tc.wantParams { + got, ok := runner.last.Params[key] + if !ok { + t.Fatalf("Params missing %q; got %#v", key, runner.last.Params) + } + if !equalAny(got, want) { + t.Fatalf("Params[%q] = %#v, want %#v", key, got, want) + } + } + for _, key := range tc.wantAbsent { + if _, ok := runner.last.Params[key]; ok { + t.Fatalf("Params[%q] unexpectedly present; got %#v", key, runner.last.Params) + } + } + assertReplyContent(t, runner.last.Params["content"], tc.extraArgs[1]) + }) + } +} + +func TestChatMessageReplyRejectsMissingRequiredFlags(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing-conversation-id", + args: []string{"--ref-msg-id", "msg-123", "--ref-sender", "sender-op", "--text", "收到"}, + wantErr: "--conversation-id is required", + }, + { + name: "missing-ref-msg-id", + args: []string{"--conversation-id", "cid-xyz", "--ref-sender", "sender-op", "--text", "收到"}, + wantErr: "--ref-msg-id is required", + }, + { + name: "missing-ref-sender", + args: []string{"--conversation-id", "cid-xyz", "--ref-msg-id", "msg-123", "--text", "收到"}, + wantErr: "--ref-sender is required", + }, + { + name: "missing-text", + args: []string{"--conversation-id", "cid-xyz", "--ref-msg-id", "msg-123", "--ref-sender", "sender-op"}, + wantErr: "--text is required", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runChatMessageReply(t, tc.args...) + if err == nil { + t.Fatalf("expected error, got nil; output: %s", out) + } + if got := err.Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("error = %q, want to contain %q", got, tc.wantErr) + } + }) + } +} + +func runChatMessageReply(t *testing.T, args ...string) (*captureRunner, string, error) { + t.Helper() + runner := &captureRunner{} + cmd := newChatMessageReplyCommand(runner) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return runner, out.String(), err +} + +func assertReplyContent(t *testing.T, raw any, wantText string) { + t.Helper() + content, ok := raw.(string) + if !ok { + t.Fatalf("content = %#v, want string", raw) + } + if strings.Contains(wantText, "<@") && !strings.Contains(content, wantText) { + t.Fatalf("raw content = %q, want literal mention text %q", content, wantText) + } + var got map[string]string + if err := json.Unmarshal([]byte(content), &got); err != nil { + t.Fatalf("content %q is not valid JSON: %v", content, err) + } + if got["referenceOpenMessageId"] != "msg-123" { + t.Fatalf("referenceOpenMessageId = %q, want msg-123", got["referenceOpenMessageId"]) + } + if got["srcMsgSendOpenDingTalkId"] != "sender-op" { + t.Fatalf("srcMsgSendOpenDingTalkId = %q, want sender-op", got["srcMsgSendOpenDingTalkId"]) + } + if got["replyMsgType"] != "text" { + t.Fatalf("replyMsgType = %q, want text", got["replyMsgType"]) + } + if got["content"] != wantText { + t.Fatalf("content = %q, want %q", got["content"], wantText) + } +} diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index b52ed1d5..e297f448 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -1532,7 +1532,7 @@ Flags: - `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选,不传由服务端默认分配 - `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取) - `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID -- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text +- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);可选 --at-open-dingtalk-ids(逗号分隔)或 --at-all,使用 @ 时 --text 需包含对应的 <@openDingTalkId> / <@all> 占位符;目前回复类型仅支持 text - `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId) - `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消 - `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致 diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index c30566e7..bfc044c7 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -1532,7 +1532,7 @@ Flags: - `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选,不传由服务端默认分配 - `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取) - `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID -- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text +- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);可选 --at-open-dingtalk-ids(逗号分隔)或 --at-all,使用 @ 时 --text 需包含对应的 <@openDingTalkId> / <@all> 占位符;目前回复类型仅支持 text - `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId) - `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消 - `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致