// 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 ( "context" "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/spf13/cobra" ) func init() { RegisterPublic(func() Handler { return chatHandler{} }) } 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。", 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( newChatMessageSendByBotCommand(runner), newChatMessageRecallByBotCommand(runner), newChatMessageSendByWebhookCommand(runner), ) 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(newChatBotSearchCommand(runner)) root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot) return root } 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) 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("robot-code", "", "机器人 Code") cmd.Flags().String("text", "", "消息内容 (Markdown)") cmd.Flags().String("title", "", "消息标题") cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)") return cmd } func newChatSearchCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "search", Short: "根据名称搜索会话列表", Example: ` dws chat search --query "项目冲刺"`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { query, err := cmd.Flags().GetString("query") if err != nil { return apperrors.NewInternal("failed to read --query") } query = strings.TrimSpace(query) if query == "" { return apperrors.NewValidation("--query is required") } searchReq := map[string]any{"query": query} cursor, err := cmd.Flags().GetString("cursor") if err != nil { return apperrors.NewInternal("failed to read --cursor") } if strings.TrimSpace(cursor) != "" { searchReq["cursor"] = cursor } result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "search_groups_by_keyword", map[string]any{"OpenSearchRequest": searchReq}, )) if err != nil { return err } return writeCommandPayload(cmd, result) }, } preferLegacyLeaf(cmd) cmd.Flags().String("query", "", "搜索关键词 (必填)") cmd.Flags().String("cursor", "", "分页游标 (首页留空)") return cmd } func newChatGroupCommand(runner executor.Runner) *cobra.Command { root := &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: newChatGroupMembersListRunE(runner), } members.Flags().String("id", "", "群 ID / openconversation_id (必填)") members.Flags().String("cursor", "", "分页游标") preferLegacyLeaf(members) members.AddCommand( newChatGroupMemberAddCommand(runner), newChatGroupMemberRemoveCommand(runner), newChatGroupMembersAddBotCommand(runner), ) root.AddCommand( newChatGroupCreateCommand(runner), members, newChatGroupRenameCommand(runner), ) return root } 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") } currentUserID, err := getCurrentUserID(cmd.Context(), runner) if err != nil { return err } allMembers := prependOwner(currentUserID, memberUserIDs) inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "create_internal_group", map[string]any{ "groupMembers": stringSliceToAny(allMembers), "groupName": name, }, ) 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 列表,逗号分隔 (必填)") return cmd } func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (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 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 } 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 --group --keys dws chat message recall-by-bot --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 ──────────────────────────────── 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 --title "告警" --text "CPU 超 90%" --at-all dws chat message send-by-webhook --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 } // ── group members list ───────────────────────────────────── func newChatGroupMembersListRunE(runner executor.Runner) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { groupID, _ := cmd.Flags().GetString("id") if strings.TrimSpace(groupID) == "" { return apperrors.NewValidation("--id is required") } params := map[string]any{ "openconversation_id": groupID, } if v, _ := cmd.Flags().GetString("cursor"); v != "" { params["cursor"] = v } result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params, )) if err != nil { return err } return writeCommandPayload(cmd, result) } } // ── group rename ─────────────────────────────────────────── func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "rename", Short: "更新群名称", Example: ` dws chat group rename --id --name "新群名"`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { groupID, _ := cmd.Flags().GetString("id") name, _ := cmd.Flags().GetString("name") if strings.TrimSpace(groupID) == "" { return apperrors.NewValidation("--id is required") } if strings.TrimSpace(name) == "" { return apperrors.NewValidation("--name is required") } params := map[string]any{ "openconversation_id": groupID, "group_name": name, } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", 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("id", "", "群 ID / openconversation_id (必填)") cmd.Flags().String("name", "", "新群名称 (必填)") return cmd } // ── group members add ────────────────────────────────────── func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "添加群成员", Example: ` dws chat group members add --id --users userId1,userId2`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { groupID, _ := cmd.Flags().GetString("id") usersStr, _ := cmd.Flags().GetString("users") if strings.TrimSpace(groupID) == "" { return apperrors.NewValidation("--id is required") } if strings.TrimSpace(usersStr) == "" { return apperrors.NewValidation("--users is required") } params := map[string]any{ "openconversation_id": groupID, "userId": splitCSV(usersStr), } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", 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("id", "", "群 ID / openconversation_id (必填)") cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)") return cmd } // ── group members remove ─────────────────────────────────── func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Short: "移除群成员", Example: ` dws chat group members remove --id --users userId1,userId2`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { groupID, _ := cmd.Flags().GetString("id") usersStr, _ := cmd.Flags().GetString("users") if strings.TrimSpace(groupID) == "" { return apperrors.NewValidation("--id is required") } if strings.TrimSpace(usersStr) == "" { return apperrors.NewValidation("--users is required") } params := map[string]any{ "openconversationId": groupID, "userIdList": splitCSV(usersStr), } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", 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("id", "", "Group ID / openconversation_id (required)") cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)") return cmd } // ── group members add-bot ────────────────────────────────── func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "add-bot", Short: "Add bot to group", Example: ` dws chat group members add-bot --robot-code --id `, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { robotCode, _ := cmd.Flags().GetString("robot-code") groupID, _ := cmd.Flags().GetString("id") if strings.TrimSpace(robotCode) == "" { return apperrors.NewValidation("--robot-code is required") } if strings.TrimSpace(groupID) == "" { return apperrors.NewValidation("--id is required") } params := map[string]any{ "robotCode": robotCode, "openConversationId": groupID, } inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", 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", "", "Bot code (required)") cmd.Flags().String("id", "", "Group openConversationId (required)") return cmd } // ── bot search ───────────────────────────────────────────── func newChatBotSearchCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "search", Short: "Search my bots", Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { page, _ := cmd.Flags().GetInt("page") params := map[string]any{ "currentPage": page, } if v, _ := cmd.Flags().GetInt("size"); v > 0 { params["pageSize"] = v } if v, _ := cmd.Flags().GetString("name"); v != "" { params["robotName"] = v } result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params, )) if err != nil { return err } return writeCommandPayload(cmd, result) }, } preferLegacyLeaf(cmd) cmd.Flags().Int("page", 1, "Page number, starting from 1") cmd.Flags().Int("size", 0, "Items per page (default 50)") cmd.Flags().String("name", "", "Search by name") return cmd }