Files

1125 lines
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"regexp"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return chatHandler{}
})
}
// chatHandler retains only the chat commands that carry real business logic
// (intelligent tool routing, current-user resolution, response normalization,
// or stdin/@file input support that dynamic commands do not yet provide).
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
// bot search — are now produced by the dynamic service-discovery envelope
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
type chatHandler struct{}
func (chatHandler) Name() string {
return "chat"
}
func (chatHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "chat",
Short: "群聊 / 消息 / 机器人",
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message := &cobra.Command{
Use: "message",
Short: "会话消息管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message.AddCommand(
newChatMessageSendCommand(runner),
newChatMessageSendByBotCommand(runner),
newChatMessageRecallByBotCommand(runner),
newChatMessageSendByWebhookCommand(runner),
newChatMessageReplyCommand(runner),
)
group := &cobra.Command{
Use: "group",
Short: "群组管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
members := &cobra.Command{
Use: "members",
Short: "群成员管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
members.AddCommand(
newChatGroupMembersAddBotCommand(runner),
newChatGroupMembersRemoveBotCommand(runner),
)
group.AddCommand(
newChatGroupCreateCommand(runner),
newChatGroupBotsCommand(runner),
members,
)
bot := &cobra.Command{
Use: "bot",
Short: "机器人查询",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
bot.AddCommand(
newChatBotFindCommand(runner),
newChatBotSearchCommand(runner),
)
root.AddCommand(message, group, bot, newChatFileGroup(runner), newChatMediaGroup())
return root
}
// botInvoke 把 bot 相关命令统一路由到 "bot" MCP server,与 wukong 的
// callMCPToolOnServer("bot", ...) 对齐。
func botInvoke(runner executor.Runner, cmd *cobra.Command, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"bot",
tool,
params,
)
invocation.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func newChatBotFindCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "find",
Short: "搜索全部可用机器人(含他人/官方,额外返回 openDingTalkId 可发单聊)",
Long: "按关键词搜索当前用户可用的全部机器人(含他人创建、官方),支持游标分页。find 返回 openDingTalkId(可给机器人发单聊);只搜自己创建的用 dws chat bot search。",
Example: " dws chat bot find --query \"日报\"\n dws chat bot find --query \"日报\" --limit 20",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
query, _ := cmd.Flags().GetString("query")
if strings.TrimSpace(query) == "" {
query, _ = cmd.Flags().GetString("keyword")
}
if strings.TrimSpace(query) == "" {
return apperrors.NewValidation("--query is required")
}
params := map[string]any{"keyword": query}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params["limit"] = v
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
params["cursor"] = v
}
return botInvoke(runner, cmd, "search_bots", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
cmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传上次返回的 nextCursor)")
return cmd
}
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "搜索我创建的机器人",
Long: "按名称搜索当前用户自己创建的机器人。搜全部(含他人/官方)用 dws chat bot find。",
Example: " dws chat bot search --name \"日报\"",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if v, _ := cmd.Flags().GetString("name"); v != "" {
params["robotName"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v > 0 {
params["currentPage"] = v
}
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
params["pageSize"] = v
}
return botInvoke(runner, cmd, "search_my_robots", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("name", "", "机器人名称关键词(可选)")
cmd.Flags().Int("page", 0, "页码(可选)")
cmd.Flags().Int("size", 0, "每页数量(可选)")
return cmd
}
func newChatGroupBotsCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "bots",
Short: "查看群内所有机器人",
Example: " dws chat group bots --group <openConversationId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
group, _ := cmd.Flags().GetString("group")
if strings.TrimSpace(group) == "" {
return apperrors.NewValidation("--group is required")
}
return botInvoke(runner, cmd, "list_group_bots", map[string]any{"openConversationId": group})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId (必填)")
return cmd
}
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-bot",
Short: "将机器人添加到群中",
Example: " dws chat group members add-bot --id <openConversationId> --robot-code <robotCode>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetString("id")
robotCode, _ := cmd.Flags().GetString("robot-code")
if strings.TrimSpace(id) == "" || strings.TrimSpace(robotCode) == "" {
return apperrors.NewValidation("--id and --robot-code are required")
}
return botInvoke(runner, cmd, "add_robot_to_group", map[string]any{
"openConversationId": id,
"robotCode": robotCode,
})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群聊 openConversationId (必填)")
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
return cmd
}
func newChatGroupMembersRemoveBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove-bot",
Short: "从群内移除机器人",
Example: " dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetString("id")
botID, _ := cmd.Flags().GetString("bot-id")
if strings.TrimSpace(id) == "" || strings.TrimSpace(botID) == "" {
return apperrors.NewValidation("--id and --bot-id are required")
}
return botInvoke(runner, cmd, "remove_robot_in_group", map[string]any{
"openConversationId": id,
"openBotId": botID,
})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群聊 openConversationId (必填)")
cmd.Flags().String("bot-id", "", "机器人 openBotId (必填)")
return cmd
}
func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "以当前用户身份发送消息 (--group 群聊 / --user 或 --open-dingtalk-id 单聊)",
Long: `以当前用户身份发送群消息或单聊消息。
--group 指定群聊 openConversationId 发群消息;--user 指定 userId 发单聊;
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
群聊场景下可用 --at-all / --at-open-dingtalk-ids 进行 @ 提醒(仅 --group 时生效)。
富媒体:--msg-type image --media-id 发图片;--msg-type file --dentry-id --space-id --file-name 发钉盘文件。`,
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
dws chat message send --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params, tool, err := buildChatMessageSendInvocation(cmd, args)
if err != nil {
return err
}
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
tool,
params,
)
invocation.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openConversationId (群聊三选一)")
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (可选,未指定时从内容截取)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 群聊生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
cmd.Flags().String("uuid", "", "幂等 UUID (可选,24h 内相同 uuid 不重复发送)")
cmd.Flags().String("msg-type", "", "富媒体类型: image / file (纯文本/Markdown 留空)")
cmd.Flags().String("media-id", "", "图片 mediaId (msg-type=image 时必填)")
cmd.Flags().Int64("dentry-id", 0, "钉盘文件 dentryId (msg-type=file 时必填)")
cmd.Flags().Int64("space-id", 0, "钉盘空间 ID (msg-type=file 时必填)")
cmd.Flags().String("file-name", "", "文件名 (msg-type=file 时必填)")
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
// attachAITag 在 --ai-tag 为真时给发送参数加上 clawType,由 IM 服务端据此
// 渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。--ai-tag 默认 true:
// 经 dws/agent 代发的消息默认带角标以透明标识 AI/CLI 代发,仅当用户显式
// 传 --ai-tag=false 时才不带 (按本人发送)。
func attachAITag(cmd *cobra.Command, params map[string]any) {
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
params["clawType"] = edition.ClawType()
}
}
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
// content 内携带 title)。
func deriveTitleFromText(text string) string {
t := strings.TrimSpace(text)
if i := strings.IndexAny(t, "\r\n"); i >= 0 {
t = strings.TrimSpace(t[:i])
}
r := []rune(t)
if len(r) > 20 {
r = r[:20]
}
if len(r) == 0 {
return "消息"
}
return string(r)
}
func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[string]any, string, error) {
guard := cli.NewStdinGuard()
group, err := cmd.Flags().GetString("group")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --group")
}
user, err := cmd.Flags().GetString("user")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --user")
}
openID, err := cmd.Flags().GetString("open-dingtalk-id")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --open-dingtalk-id")
}
title, err := resolveStringFlag(cmd, "title", guard, false)
if err != nil {
return nil, "", err
}
// --text is the primary content flag: receives stdin pipe and positional
// fallback when empty.
text, err := resolveStringFlag(cmd, "text", guard, true)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(text) == "" && len(args) > 0 {
text = args[0]
}
uuid, _ := cmd.Flags().GetString("uuid")
hasGroup := strings.TrimSpace(group) != ""
hasUser := strings.TrimSpace(user) != ""
hasOpenID := strings.TrimSpace(openID) != ""
specified := 0
if hasGroup {
specified++
}
if hasUser {
specified++
}
if hasOpenID {
specified++
}
switch specified {
case 0:
return nil, "", apperrors.NewValidation("one of --group, --user, or --open-dingtalk-id is required")
case 1:
default:
return nil, "", apperrors.NewValidation("--group, --user, and --open-dingtalk-id are mutually exclusive")
}
// ── 富媒体消息 (image / file):走 send_personal_message,后端 schema 支持
// content + msgType (image 经 content 携带 mediaId;file 经 content 携带
// dentryId/spaceId)。本地 --file-path 自动上传暂未移植,使用钉盘 dentry/space。
msgType, _ := cmd.Flags().GetString("msg-type")
if msgType == "text" || msgType == "markdown" {
msgType = ""
}
if msgType != "" {
var contentJSON string
switch msgType {
case "image":
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return nil, "", apperrors.NewValidation("--media-id is required for --msg-type image")
}
b, _ := json.Marshal(map[string]string{"mediaId": mediaID})
contentJSON = string(b)
case "file":
dentryID, _ := cmd.Flags().GetInt64("dentry-id")
spaceID, _ := cmd.Flags().GetInt64("space-id")
fileName, _ := cmd.Flags().GetString("file-name")
if dentryID == 0 || spaceID == 0 || strings.TrimSpace(fileName) == "" {
return nil, "", apperrors.NewValidation("--msg-type file 需要 --dentry-id、--space-id、--file-name (本地 --file-path 自动上传暂未支持)")
}
fileType, _ := cmd.Flags().GetString("file-type")
filePath, _ := cmd.Flags().GetString("file-path")
fileSize, _ := cmd.Flags().GetInt64("file-size")
b, _ := json.Marshal(map[string]any{
"dentryId": dentryID, "spaceId": spaceID, "fileName": fileName,
"fileType": fileType, "filePath": filePath, "fileSize": fileSize,
})
contentJSON = string(b)
default:
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
}
params := map[string]any{"msgType": msgType, "content": contentJSON}
attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
switch {
case hasGroup:
params["openConversationId"] = group
case hasOpenID:
params["receiverOpenDingTalkId"] = openID
default:
return nil, "", apperrors.NewValidation("--msg-type image/file 需配合 --group 或 --open-dingtalk-id (--user 暂不支持富媒体)")
}
return params, "send_personal_message", nil
}
// ── 文本 / Markdown 消息 ──
if strings.TrimSpace(text) == "" {
return nil, "", apperrors.NewValidation("--text (or positional argument) is required")
}
if strings.TrimSpace(title) == "" {
title = deriveTitleFromText(text)
}
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIDs, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
hasAtOpenIDs := strings.TrimSpace(atOpenIDs) != ""
if !hasGroup && (atAll || hasAtOpenIDs) {
return nil, "", apperrors.NewValidation("--at-all / --at-open-dingtalk-ids only apply when --group is set")
}
switch {
case hasGroup:
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
params := map[string]any{
"openConversationId": group,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
}
attachAITag(cmd, params)
if atAll {
params["atAll"] = true
}
if hasAtOpenIDs {
params["atOpenDingTalkIds"] = splitCSVStrings(atOpenIDs)
}
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
return params, "send_personal_message", nil
case hasUser:
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
attachAITag(cmd, params)
return params, "send_direct_message_as_user", nil
default:
params := map[string]any{
"receiverOpenDingTalkId": openID,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
}
attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
return params, "send_personal_message", nil
}
}
func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "send-by-bot",
Short: "机器人发送消息(--group 群聊 / --users 单聊)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params, tool, err := buildChatMessageSendByBotInvocation(cmd, runner)
if err != nil {
return err
}
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"bot",
tool,
params,
)
invocation.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openConversationId (群聊必填)")
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-user-ids", "", "@指定成员 userId 列表,逗号分隔 (仅 --group 生效);文中的 <@userId> 会被替换为 markdown @userId 语法")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
return cmd
}
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "创建群",
Example: ` dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name, err := cmd.Flags().GetString("name")
if err != nil {
return apperrors.NewInternal("failed to read --name")
}
name = strings.TrimSpace(name)
if name == "" {
return apperrors.NewValidation("--name is required")
}
users, err := cmd.Flags().GetString("users")
if err != nil {
return apperrors.NewInternal("failed to read --users")
}
memberUserIDs := splitCSVStrings(users)
if len(memberUserIDs) == 0 {
return apperrors.NewValidation("--users is required")
}
groupType := strings.ToUpper(strings.TrimSpace(cmd.Flags().Lookup("type").Value.String()))
if groupType == "" {
groupType = "INTERNAL"
}
switch groupType {
case "INTERNAL", "EXTERNAL", "NORMAL":
default:
return apperrors.NewValidation("--type must be one of INTERNAL, EXTERNAL, NORMAL")
}
threadEnabled, _ := cmd.Flags().GetBool("thread")
currentUserID, err := getCurrentUserID(cmd.Context(), runner)
if err != nil {
return err
}
allMembers := prependOwner(currentUserID, memberUserIDs)
// create_group_conversation (multi-type + thread support) and the
// legacy create_internal_group live on two different MCP servers
// ("im" and "group-chat") that both publish `dws chat ...`. Route
// each tool to its owning server explicitly so direct-runtime
// endpoint resolution does not collapse them onto the shared
// cli.command endpoint (which would send create_group_conversation
// to the group-chat server, where it is not registered).
product := "im"
tool := "create_group_conversation"
params := map[string]any{
"groupMembers": stringSliceToAny(allMembers),
"groupName": name,
"groupType": groupType,
"convThreadEnabled": threadEnabled,
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
product,
tool,
params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
normalizeChatGroupCreateResponse(&result)
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("name", "", "群名称 (必填)")
cmd.Flags().String("users", "", "群成员 userId 列表,逗号分隔 (必填)")
cmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(企业内部群) / EXTERNAL(外部群) / NORMAL(普通群),默认 INTERNAL")
cmd.Flags().Bool("thread", false, "开启话题圈 (convThreadEnabled)")
return cmd
}
func buildChatMessageSendByBotInvocation(cmd *cobra.Command, runner executor.Runner) (map[string]any, string, error) {
guard := cli.NewStdinGuard()
group, err := cmd.Flags().GetString("group")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --group")
}
users, err := cmd.Flags().GetString("users")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --users")
}
robotCode, err := cmd.Flags().GetString("robot-code")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --robot-code")
}
title, err := resolveStringFlag(cmd, "title", guard, false)
if err != nil {
return nil, "", err
}
// --text is the primary content flag: receives stdin pipe when empty.
text, err := resolveStringFlag(cmd, "text", guard, true)
if err != nil {
return nil, "", err
}
switch {
case strings.TrimSpace(group) == "" && strings.TrimSpace(users) == "":
return nil, "", apperrors.NewValidation("either --group or --users is required")
case strings.TrimSpace(group) != "" && strings.TrimSpace(users) != "":
return nil, "", apperrors.NewValidation("--group and --users are mutually exclusive")
}
if strings.TrimSpace(robotCode) == "" {
return nil, "", apperrors.NewValidation("--robot-code is required")
}
if strings.TrimSpace(title) == "" {
return nil, "", apperrors.NewValidation("--title is required")
}
if strings.TrimSpace(text) == "" {
return nil, "", apperrors.NewValidation("--text is required")
}
params := map[string]any{
"markdown": text,
"robotCode": robotCode,
"title": title,
}
if strings.TrimSpace(group) != "" {
params["openConversationId"] = group
atAll, _ := cmd.Flags().GetBool("at-all")
atUserIDs, _ := cmd.Flags().GetString("at-user-ids")
atOpenIDs, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
if atAll {
params["atAll"] = true
}
userIDList := splitCSVStrings(atUserIDs)
openIDList := splitCSVStrings(atOpenIDs)
// The robot group message API honors two @ dimensions directly:
// atUserIds (staffId) and atOpendingtalkIds (openDingTalkId — note the
// server's lowercase spelling, verified live: the camelCase
// atOpenDingTalkIds is silently ignored). openDingTalkId is the ONLY id
// a bot has, so forwarding it verbatim is what makes bot→bot @ work —
// no userId reverse-lookup needed (and a bot is not in the member
// roster, so a lookup could never resolve it anyway). Each dimension
// also needs the matching `@id` token in the body to render the chip
// (renderAtMentions below rewrites `<@id>` → `@id` and prepends any
// missing mention).
if len(userIDList) > 0 {
params["atUserIds"] = stringSliceToAny(userIDList)
}
if len(openIDList) > 0 {
params["atOpendingtalkIds"] = stringSliceToAny(openIDList)
}
if allIDs := append(append([]string{}, userIDList...), openIDList...); len(allIDs) > 0 {
params["markdown"] = renderAtMentions(params["markdown"].(string), allIDs)
}
return params, "send_robot_group_message", nil
}
params["userIds"] = splitCSV(users)
return params, "batch_send_robot_msg_to_users", nil
}
func splitCSV(raw string) []any {
parts := strings.Split(raw, ",")
values := make([]any, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
values = append(values, trimmed)
}
return values
}
func splitCSVStrings(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
values = append(values, trimmed)
}
return values
}
// atMentionPlaceholder captures both <@userId> (angle-bracket) and standalone
// @userId placeholders so a caller can write `<@u123>` or `@u123` in the body
// and have it rendered as a DingTalk mention chip.
var atMentionPlaceholder = regexp.MustCompile(`<@([^>\s]+)>`)
// renderAtMentions rewrites mention placeholders in the markdown body so the
// DingTalk client renders a highlight chip. In DingTalk markdown, a mention is
// the literal token `@userId` followed by whitespace; the recipient side then
// looks up the userId in the message's atUserIds array to render it as a chip.
// We (a) rewrite `<@userId>` → `@userId `, and (b) if none of the mentioned
// userIds appear in the body at all, prepend them so the chip still shows up.
func renderAtMentions(body string, userIDs []string) string {
if len(userIDs) == 0 {
return body
}
rewritten := atMentionPlaceholder.ReplaceAllString(body, "@$1 ")
referenced := map[string]bool{}
matches := atMentionPlaceholder.FindAllStringSubmatch(body, -1)
for _, m := range matches {
if len(m) > 1 {
referenced[m[1]] = true
}
}
// Also detect bare @userId occurrences already present in the text.
for _, uid := range userIDs {
if strings.Contains(rewritten, "@"+uid) {
referenced[uid] = true
}
}
var missing []string
for _, uid := range userIDs {
if !referenced[uid] {
missing = append(missing, "@"+uid)
}
}
if len(missing) > 0 {
prefix := strings.Join(missing, " ")
if strings.TrimSpace(rewritten) == "" {
rewritten = prefix
} else {
rewritten = prefix + " " + rewritten
}
}
return rewritten
}
func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}
func getCurrentUserID(ctx context.Context, runner executor.Runner) (string, error) {
result, err := runner.Run(ctx, executor.NewHelperInvocation(
"contact raw get_current_user_profile",
"contact",
"get_current_user_profile",
nil,
))
if err != nil {
return "", err
}
content := helperResponseContent(result)
if len(content) == 0 {
// EchoRunner and dry-run previews do not have runtime content, so fall back
// to the explicitly provided members instead of failing the preview path.
if !result.Invocation.Implemented {
return "", nil
}
return "", apperrors.NewInternal("contact.get_current_user_profile returned no content")
}
if arr, ok := content["result"].([]any); ok && len(arr) > 0 {
if first, ok := arr[0].(map[string]any); ok {
if employee, ok := first["orgEmployeeModel"].(map[string]any); ok {
if userID, ok := employee["userId"].(string); ok && strings.TrimSpace(userID) != "" {
return userID, nil
}
}
}
}
if object, ok := content["result"].(map[string]any); ok {
if userID, ok := object["userId"].(string); ok && strings.TrimSpace(userID) != "" {
return userID, nil
}
}
return "", apperrors.NewInternal("unable to parse userId from contact.get_current_user_profile")
}
func prependOwner(owner string, memberUserIDs []string) []string {
seen := map[string]bool{}
allMembers := make([]string, 0, len(memberUserIDs)+1)
if trimmedOwner := strings.TrimSpace(owner); trimmedOwner != "" {
seen[trimmedOwner] = true
allMembers = append(allMembers, trimmedOwner)
}
for _, userID := range memberUserIDs {
trimmed := strings.TrimSpace(userID)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
allMembers = append(allMembers, trimmed)
}
return allMembers
}
func normalizeChatGroupCreateResponse(result *executor.Result) {
if result == nil {
return
}
content := helperResponseContent(*result)
if len(content) == 0 {
return
}
payload, ok := content["result"].(map[string]any)
if !ok {
return
}
if value, exists := payload["openCid"]; exists {
payload["openConversationId"] = value
delete(payload, "openCid")
}
delete(payload, "cid")
}
func helperResponseContent(result executor.Result) map[string]any {
if len(result.Response) == 0 {
return nil
}
content, _ := result.Response["content"].(map[string]any)
return content
}
// ── message recall-by-bot ──────────────────────────────────
func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "recall-by-bot",
Short: "机器人撤回消息(--group 群聊 / 不传为单聊)",
Long: "群聊撤回:传 --group 和 --keys;单聊撤回:只传 --keys。--keys 是逗号分隔的 processQueryKey 列表。",
Example: ` dws chat message recall-by-bot --robot-code <robot-code> --group <id> --keys <key>
dws chat message recall-by-bot --robot-code <robot-code> --keys key1,key2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
robotCode, _ := cmd.Flags().GetString("robot-code")
keysStr, _ := cmd.Flags().GetString("keys")
groupID, _ := cmd.Flags().GetString("group")
if strings.TrimSpace(robotCode) == "" {
return apperrors.NewValidation("--robot-code is required")
}
if strings.TrimSpace(keysStr) == "" {
return apperrors.NewValidation("--keys is required")
}
processQueryKeys := splitCSV(keysStr)
if strings.TrimSpace(groupID) != "" {
params := map[string]any{
"robotCode": robotCode,
"openConversationId": groupID,
"processQueryKeys": processQueryKeys,
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "recall_robot_group_message", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
params := map[string]any{
"robotCode": robotCode,
"processQueryKeys": processQueryKeys,
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "batch_recall_robot_users_msg", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
cmd.Flags().String("group", "", "群会话 openConversationId (群聊撤回必填)")
cmd.Flags().String("keys", "", "逗号分隔的消息 processQueryKey 列表 (必填)")
return cmd
}
// ── message send-by-webhook ────────────────────────────────
//
// Kept as a helper (rather than delegating to the dynamic envelope) because
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
// dynamic-command layer does not yet provide.
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "send-by-webhook",
Short: "自定义机器人 Webhook 发送群消息",
Long: "自定义机器人 Webhook 发送群消息。如需 @指定人,在 --text 中包含 @userId 或 @手机号。",
Example: ` dws chat message send-by-webhook --token <token> --title "告警" --text "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <token> --title "test" --text "hi" --at-users 034766`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
guard := cli.NewStdinGuard()
token, _ := cmd.Flags().GetString("token")
title, err := resolveStringFlag(cmd, "title", guard, false)
if err != nil {
return err
}
// --text is the primary content flag: receives stdin pipe when empty.
text, err := resolveStringFlag(cmd, "text", guard, true)
if err != nil {
return err
}
if strings.TrimSpace(token) == "" {
return apperrors.NewValidation("--token is required")
}
if strings.TrimSpace(title) == "" {
return apperrors.NewValidation("--title is required")
}
if strings.TrimSpace(text) == "" {
return apperrors.NewValidation("--text is required")
}
params := map[string]any{
"robotToken": token,
"title": title,
"text": text,
}
if v, _ := cmd.Flags().GetBool("at-all"); v {
params["isAtAll"] = true
}
if v, _ := cmd.Flags().GetString("at-mobiles"); v != "" {
params["atMobiles"] = splitCSV(v)
}
if v, _ := cmd.Flags().GetString("at-users"); v != "" {
params["atUserIds"] = splitCSV(v)
}
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "send_message_by_custom_robot", params,
)
invocation.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("token", "", "Webhook token (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("text", "", "消息内容 (必填)")
cmd.Flags().Bool("at-all", false, "@所有人")
cmd.Flags().String("at-mobiles", "", "按手机号 @,逗号分隔")
cmd.Flags().String("at-users", "", "按 userId @,逗号分隔")
return cmd
}
// ── message reply ────────────────────────────────────────
//
// Kept as a helper because the underlying MCP tool send_personal_message
// requires the reply payload to be a JSON-encoded string assembled from
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
// flag→param mapping only and cannot construct nested JSON, so this
// orchestration must live in CLI code.
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 回复内容。",
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
convID, _ := cmd.Flags().GetString("conversation-id")
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
refSender, _ := cmd.Flags().GetString("ref-sender")
text, _ := cmd.Flags().GetString("text")
if strings.TrimSpace(convID) == "" {
return apperrors.NewValidation("--conversation-id is required")
}
if strings.TrimSpace(refMsgID) == "" {
return apperrors.NewValidation("--ref-msg-id is required")
}
if strings.TrimSpace(refSender) == "" {
return apperrors.NewValidation("--ref-sender is required")
}
if strings.TrimSpace(text) == "" {
return apperrors.NewValidation("--text is required")
}
replyContent := map[string]any{
"referenceOpenMessageId": refMsgID,
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": text,
}
contentJSON, err := jsonMarshal(replyContent)
if err != nil {
return apperrors.NewInternal("marshal reply content: " + err.Error())
}
params := map[string]any{
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
}
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
attachAITag(cmd, params)
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"group-chat",
"send_personal_message",
params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// marshalMessageContent builds the send_personal_message content payload
// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
// message text; the default json.Marshal escaping turns them into
// <@...>, which the client shows as plain text instead of a rendered
// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
func marshalMessageContent(title, text string) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
// Encoder errors are impossible for a map[string]string; ignore safely.
_ = enc.Encode(map[string]string{"title": title, "text": text})
// Encoder.Encode appends a trailing newline; strip it.
return strings.TrimRight(buf.String(), "\n")
}