From 62dfe871b7b6858a2a87055aaf28c890685def73 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:30:25 -0700 Subject: [PATCH 1/3] feat: support @ mentions in chat message reply via at-open-dingtalk-ids and at-all flags --- CHANGELOG.md | 4 + internal/helpers/chat.go | 12 +- internal/helpers/chat_test.go | 148 ++++++++++++++++++ skills/mono/references/products/chat.md | 2 +- skills/multi/dingtalk-chat/references/chat.md | 2 +- 5 files changed, 165 insertions(+), 3 deletions(-) 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..7a54e635 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,6 +1007,8 @@ 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 } diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index a2d2b95b..502455ef 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,150 @@ 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{"isAtAll"}, + }, + { + name: "at-all", + extraArgs: []string{"--text", "<@all> 收到,马上处理", "--at-all"}, + wantParams: map[string]any{ + "isAtAll": true, + }, + wantAbsent: []string{"atOpenDingTalkIds"}, + }, + { + name: "without-at-flags", + extraArgs: []string{"--text", "收到,马上处理"}, + wantAbsent: []string{"atOpenDingTalkIds", "isAtAll"}, + }, + { + name: "whitespace-at-open-dingtalk-ids", + extraArgs: []string{"--text", "收到,马上处理", "--at-open-dingtalk-ids", " "}, + wantAbsent: []string{"atOpenDingTalkIds", "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) + } + 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` 的用户身份发送语义一致 From 62cbbfc1ba5f319abce435677241da1ce6b4c4ff Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:40:46 -0700 Subject: [PATCH 2/3] fix: address self-review findings --- internal/helpers/chat.go | 10 ++++++---- internal/helpers/chat_test.go | 13 ++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 7a54e635..280a8f83 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -986,7 +986,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { } } if v, _ := cmd.Flags().GetBool("at-all"); v { - params["isAtAll"] = true + params["atAll"] = true } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), @@ -1014,11 +1014,13 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { } 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 502455ef..326f6dc4 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -400,25 +400,25 @@ func TestChatMessageReplyForwardsAtMentions(t *testing.T) { wantParams: map[string]any{ "atOpenDingTalkIds": []string{"op-1", "op-2"}, }, - wantAbsent: []string{"isAtAll"}, + wantAbsent: []string{"atAll", "isAtAll"}, }, { name: "at-all", extraArgs: []string{"--text", "<@all> 收到,马上处理", "--at-all"}, wantParams: map[string]any{ - "isAtAll": true, + "atAll": true, }, - wantAbsent: []string{"atOpenDingTalkIds"}, + wantAbsent: []string{"atOpenDingTalkIds", "isAtAll"}, }, { name: "without-at-flags", extraArgs: []string{"--text", "收到,马上处理"}, - wantAbsent: []string{"atOpenDingTalkIds", "isAtAll"}, + wantAbsent: []string{"atOpenDingTalkIds", "atAll", "isAtAll"}, }, { name: "whitespace-at-open-dingtalk-ids", extraArgs: []string{"--text", "收到,马上处理", "--at-open-dingtalk-ids", " "}, - wantAbsent: []string{"atOpenDingTalkIds", "isAtAll"}, + wantAbsent: []string{"atOpenDingTalkIds", "atAll", "isAtAll"}, }, } for _, tc := range cases { @@ -516,6 +516,9 @@ func assertReplyContent(t *testing.T, raw any, wantText 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) From c49f6cbcc97fd38ce867b8e2ecc306b560d98ff8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:46:10 -0700 Subject: [PATCH 3/3] fix: forward --at-all as isAtAll per upstream tool schema --- internal/helpers/chat.go | 2 +- internal/helpers/chat_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 280a8f83..ba6b6cfd 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -986,7 +986,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command { } } if v, _ := cmd.Flags().GetBool("at-all"); v { - params["atAll"] = true + params["isAtAll"] = true } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index 326f6dc4..564fa92d 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -406,9 +406,9 @@ func TestChatMessageReplyForwardsAtMentions(t *testing.T) { name: "at-all", extraArgs: []string{"--text", "<@all> 收到,马上处理", "--at-all"}, wantParams: map[string]any{ - "atAll": true, + "isAtAll": true, }, - wantAbsent: []string{"atOpenDingTalkIds", "isAtAll"}, + wantAbsent: []string{"atOpenDingTalkIds", "atAll"}, }, { name: "without-at-flags",