Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions internal/helpers/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
151 changes: 151 additions & 0 deletions internal/helpers/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion skills/mono/references/products/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的用户身份发送语义一致
Expand Down
2 changes: 1 addition & 1 deletion skills/multi/dingtalk-chat/references/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的用户身份发送语义一致
Expand Down