package helpers import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" "unicode" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { roleIDChanged := cmd.Flags().Changed("role-id") roleIDsChanged := cmd.Flags().Changed("role-ids") switch { case roleIDChanged && roleIDsChanged: // PreRunE rejects callers that explicitly pass both flags. Reaching this // branch means the hidden legacy flag was promoted to satisfy Cobra's // required marker on the public canonical flag. return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil case roleIDChanged: roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) if len(roleIDs) == 0 { return nil, apperrors.NewValidation("--role-id 不能为空") } if len(roleIDs) > 1 { return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份") } return roleIDs, nil case roleIDsChanged: return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil default: return nil, apperrors.NewValidation("缺少必填参数 --role-id") } } func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error { if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") { return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") } return promoteLegacyChatString(cmd, "role-id", "role-ids") } // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when // both spellings are present. func promoteLegacyChatString(cmd *cobra.Command, canonical, legacy string) error { if !cmd.Flags().Changed(legacy) { return nil } value, err := cmd.Flags().GetString(legacy) if err != nil { return err } return cmd.Flags().Set(canonical, value) } func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error { if deps.Caller.DryRun() { return callMCPToolOnServer("chat", toolName, args) } text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args) if err != nil { var cliErr *CLIError if errors.As(err, &cliErr) && cliErr.Operation == "" { withOperation := *cliErr withOperation.Operation = "chat/" + toolName return &withOperation } return err } if strings.TrimSpace(text) == "" { return apperrors.NewAPI("MCP read tool returned no non-empty text content", apperrors.WithOperation("chat/"+toolName), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(true), apperrors.WithReason("empty_tool_response"), ) } data := map[string]any{} if err := unmarshalJSONUseNumber(text, &data); err != nil { deps.Out.PrintRaw(text) return nil } return writeCommandPayload(cmd, projectChatMessagesPayload(data, search)) } func projectChatMessagesPayload(data map[string]any, search bool) map[string]any { items := chatmsg.ListMessageItems(data) if search { items = chatmsg.SearchItems(data) } messages := make([]map[string]any, 0, len(items)) for _, item := range items { projected := make(map[string]any, len(item)+8) for key, value := range item { projected[key] = value } for key, value := range chatmsg.ProjectMessageV1(item, true) { if key == "messageId" || key == "text" { projected[key] = value continue } if _, exists := projected[key]; !exists { projected[key] = value } } if value, exists := item["openMessageId"]; exists { projected["openMessageId"] = value } else if value, exists := projected["messageId"]; exists { projected["openMessageId"] = value } if value, exists := item["content"]; exists { projected["content"] = value } else if value, exists := projected["text"]; exists { projected["content"] = value } messages = append(messages, projected) } payload := make(map[string]any, len(data)+1) for key, value := range data { payload[key] = value } payload["messages"] = messages return payload } func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) { forwardStr, _ := cmd.Flags().GetString("forward") forward := forwardStr != "false" if !cmd.Flags().Changed("direction") { if !cmd.Flags().Changed("forward") { return defaultForward, nil } return forward, nil } direction, _ := cmd.Flags().GetString("direction") switch strings.TrimSpace(strings.ToLower(direction)) { case "newer": if cmd.Flags().Changed("forward") && !forward { return false, fmt.Errorf("--direction newer conflicts with --forward=false") } return true, nil case "older": if cmd.Flags().Changed("forward") && forward { return false, fmt.Errorf("--direction older conflicts with --forward=true") } return false, nil case "": return defaultForward, nil default: return false, fmt.Errorf("--direction must be newer or older") } } func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command { command := hintSubCmd(use, hint) // Legacy callers may still pass the old command's flags. Let the migration // command consume them so Cobra reaches RunE and returns the replacement path. command.DisableFlagParsing = true return command } type nativeChatTargetReader struct{} func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { text, err := CallMCPReadToolTextOnServer(product, tool, params) if err != nil { return nil, err } if strings.TrimSpace(text) == "" { return map[string]any{}, nil } var data map[string]any if err := json.Unmarshal([]byte(text), &data); err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) } return data, nil } func resolveNativeChatTarget(raw string) (string, error) { raw = strings.TrimSpace(raw) if targetresolver.LooksLikeOpenConversationID(raw) { return raw, nil } resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw) if err != nil { return "", err } return resolved.Selected.OpenConversationID, nil } const ( maxConversationCategoryTitleRunes = 15 chatFavoritesMaxPageSize = 30 ) func validatedConversationCategoryTitle(raw string) (string, error) { title := strings.TrimSpace(raw) if title == "" { return "", apperrors.NewValidation("--title 不能为空") } if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes { return "", apperrors.NewValidation(fmt.Sprintf( "--title 最多 %d 个字符", maxConversationCategoryTitleRunes)) } return title, nil } func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) int { for _, alias := range aliases { if f := cmd.Flags().Lookup(alias); f != nil && f.Changed { v, _ := cmd.Flags().GetInt(alias) return v } } v, _ := cmd.Flags().GetInt(primary) return v } func chatFlagOrAlias(cmd *cobra.Command, primary string, aliases ...string) (string, error) { value, _ := cmd.Flags().GetString(primary) value = strings.TrimSpace(value) for _, alias := range aliases { aliasValue, _ := cmd.Flags().GetString(alias) aliasValue = strings.TrimSpace(aliasValue) if aliasValue == "" { continue } if value != "" && aliasValue != value { return "", fmt.Errorf("--%s conflicts with --%s", alias, primary) } if value == "" { value = aliasValue } } return value, nil } func requireChatFlagOrAlias(cmd *cobra.Command, primary string, aliases ...string) (string, error) { value, err := chatFlagOrAlias(cmd, primary, aliases...) if err != nil { return "", err } if value != "" { return value, nil } return "", validateRequiredFlagWithAliases(cmd, primary, aliases...) } func chatConversationID(cmd *cobra.Command) (string, error) { return chatFlagOrAlias(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id") } func requireChatConversationID(cmd *cobra.Command) (string, error) { return requireChatFlagOrAlias(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id") } func chatMessageID(cmd *cobra.Command) (string, error) { return requireChatFlagOrAlias(cmd, "message-id", "msg-id", "open-message-id") } func installChatIMIDFlagAliases(root *cobra.Command) { conversationMigrationPaths := chatMigrationPathSet(chatPendingConversationIDMigrationPaths) messageMigrationPaths := chatMigrationPathSet(chatPendingMessageIDMigrationPaths) var visit func(*cobra.Command, []string) visit = func(cmd *cobra.Command, path []string) { key := strings.Join(path, " ") if conversationMigrationPaths[key] { installChatFlagAliases(cmd, "conversation-id", []string{"group", "id", "chat", "open-conversation-id"}, requireChatConversationID) clearChatAliasRequiredAnnotations(cmd, "group", "id", "chat", "open-conversation-id") } if messageMigrationPaths[key] { installChatFlagAliases(cmd, "message-id", []string{"msg-id", "open-message-id"}, chatMessageID) clearChatAliasRequiredAnnotations(cmd, "msg-id", "open-message-id") } for _, child := range cmd.Commands() { visit(child, append(path, child.Name())) } } visit(root, nil) restoreChatGroupBotsLegacyRequired(root) restoreChatPendingMigrationCanonicalRequired(root) restoreChatManifestExternalVisibleFlags(root) } func chatMigrationPathSet(paths [][]string) map[string]bool { set := make(map[string]bool, len(paths)) for _, path := range paths { set[strings.Join(path, " ")] = true } return set } func restoreChatManifestExternalVisibleFlags(root *cobra.Command) { if root == nil { return } for _, spec := range chatManifestExternalVisibleFlagSpecs { cmd, _, err := root.Find(spec.path) if err != nil || cmd == nil { continue } for _, flag := range spec.flags { ensureVisibleStringFlag(cmd, flag.name, flag.usage) } for _, flag := range spec.optionalFlags { if f := cmd.Flags().Lookup(flag); f != nil && f.Annotations != nil { delete(f.Annotations, cobra.BashCompOneRequiredFlag) } } } } func ensureVisibleStringFlag(cmd *cobra.Command, name, usage string) { flag := cmd.Flags().Lookup(name) if flag == nil { cmd.Flags().String(name, "", usage) flag = cmd.Flags().Lookup(name) } if flag != nil { flag.Hidden = false } } type chatVisibleFlagSpec struct { name string usage string } type chatVisibleFlagCommandSpec struct { path []string flags []chatVisibleFlagSpec optionalFlags []string } var chatManifestExternalVisibleFlagSpecs = []chatVisibleFlagCommandSpec{ {path: []string{"category", "add-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"category", "list-by-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}, optionalFlags: []string{"conversation-id"}}, {path: []string{"category", "remove-conv"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"conversation-info"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"file", "upload"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"group", "get-mute-config"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"group-mute"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"group-mute-member"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "add-emoji"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, {path: []string{"message", "add-text-emotion"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, {path: []string{"message", "list"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "list-mentions"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "recall-by-bot"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "remove-emoji"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, {path: []string{"message", "remove-text-emotion"}, flags: []chatVisibleFlagSpec{{"msg-id", "--message-id 的别名"}}}, {path: []string{"message", "search"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "send"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "send-by-bot"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}}}, {path: []string{"message", "send-card"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}, {"receiver", "--open-dingtalk-id 的兼容别名"}}}, {path: []string{"message", "update-text-emotion"}, flags: []chatVisibleFlagSpec{{"group", "--conversation-id 的别名"}, {"id", "--conversation-id 的别名"}, {"chat", "--conversation-id 的别名"}}, optionalFlags: []string{"conversation-id"}}, {path: []string{"mute"}, flags: []chatVisibleFlagSpec{{"chat", "--conversation-id 的别名"}, {"id", "--conversation-id 的别名"}}}, } func restoreChatGroupBotsLegacyRequired(root *cobra.Command) { if root == nil { return } cmd, _, err := root.Find([]string{"group", "bots"}) if err != nil || cmd == nil { return } _ = cmd.MarkFlagRequired("group") } func restoreChatPendingMigrationCanonicalRequired(root *cobra.Command) { if root == nil { return } for _, path := range chatPendingConversationIDMigrationPaths { cmd, _, err := root.Find(path) if err != nil || cmd == nil { continue } _ = cmd.MarkFlagRequired("conversation-id") clearChatAliasRequiredAnnotations(cmd, "group", "id", "chat", "open-conversation-id") } for _, path := range chatPendingMessageIDMigrationPaths { cmd, _, err := root.Find(path) if err != nil || cmd == nil { continue } _ = cmd.MarkFlagRequired("message-id") clearChatAliasRequiredAnnotations(cmd, "msg-id", "open-message-id") } } var chatPendingConversationIDMigrationPaths = [][]string{ {"group", "audit-join-validation"}, {"group", "dismiss"}, {"group", "invite-url"}, {"group", "notice", "create"}, {"group", "notice", "edit"}, {"group", "notice", "get"}, {"group", "notice", "list"}, {"group", "quit"}, {"group", "set-admin"}, {"group", "set-history"}, {"group", "transfer-owner"}, {"group", "update-alias"}, {"group", "update-icon"}, {"group", "update-nick"}, {"group", "update-settings"}, {"group", "upgrade-to-external"}, {"group-role", "add"}, {"group-role", "list"}, {"group-role", "query-user"}, {"group-role", "remove-user"}, {"group-role", "remove"}, {"group-role", "set-user"}, {"group-role", "update"}, {"message", "list-topic-replies"}, {"message", "read-status"}, } var chatPendingMessageIDMigrationPaths = [][]string{ {"message", "edit"}, {"message", "forward"}, {"message", "recall"}, {"message", "set-pin-msg"}, {"message", "set-top-msg"}, {"message", "unset-pin-msg"}, {"message", "unset-top-msg"}, {"message", "update-text-emotion"}, } func clearChatAliasRequiredAnnotations(cmd *cobra.Command, aliases ...string) { for _, alias := range aliases { flag := cmd.Flags().Lookup(alias) if flag != nil && flag.Annotations != nil { delete(flag.Annotations, cobra.BashCompOneRequiredFlag) } } } func installChatFlagAliases(cmd *cobra.Command, primary string, aliases []string, validate func(*cobra.Command) (string, error)) { flags := cmd.Flags() flag := flags.Lookup(primary) if flag == nil { return } effectiveAliases := make([]string, 0, len(aliases)) for _, alias := range aliases { if primary == "conversation-id" && alias == "group" && flags.Lookup("group-name") != nil { continue } effectiveAliases = append(effectiveAliases, alias) if flags.Lookup(alias) != nil { if aliasFlag := flags.Lookup(alias); aliasFlag != nil && aliasFlag.Annotations != nil { delete(aliasFlag.Annotations, cobra.BashCompOneRequiredFlag) } _ = flags.MarkHidden(alias) corecmd.AnnotateFlagAlias(cmd, alias, primary) continue } flags.String(alias, "", "--"+primary+" 的兼容别名") _ = flags.MarkHidden(alias) corecmd.AnnotateFlagAlias(cmd, alias, primary) } required, ok := flag.Annotations[cobra.BashCompOneRequiredFlag] wasRequired := flag.Annotations != nil && ok && len(required) > 0 && required[0] == "true" if wasRequired { delete(flag.Annotations, cobra.BashCompOneRequiredFlag) } previous := cmd.PreRunE cmd.PreRunE = func(cmd *cobra.Command, args []string) error { value, err := chatFlagOrAlias(cmd, primary, effectiveAliases...) if err != nil { return err } if value != "" { _ = cmd.Flags().Set(primary, value) } if wasRequired { if _, err := validate(cmd); err != nil { return err } } if previous != nil { return previous(cmd, args) } return nil } } const maxConversationScopedSearchPages = 40 func runConversationScopedPagedMessageSearch( cmd *cobra.Command, cfg PagedMCPCommandConfig, scopeParam string, conversationIDs []string, ) error { conversationIDs = uniqueNonEmptyStrings(conversationIDs) if len(conversationIDs) == 0 { return RunPagedMCPCommand(cmd, cfg) } toolArgs, err := cfg.BuildArgs(cmd) if err != nil { return err } return runConversationScopedMessageSearchWithProjector( cmd, cfg.ServerID, cfg.ToolName, scopeParam, toolArgs, conversationIDs, cfg.ProjectResult, ) } func runConversationScopedMessageSearch( cmd *cobra.Command, serverID, toolName, scopeParam string, toolArgs map[string]any, conversationIDs []string, ) error { return runConversationScopedMessageSearchWithProjector( cmd, serverID, toolName, scopeParam, toolArgs, conversationIDs, nil, ) } func runConversationScopedMessageSearchWithProjector( cmd *cobra.Command, serverID, toolName, scopeParam string, toolArgs map[string]any, conversationIDs []string, projectResult func(map[string]any) map[string]any, ) error { conversationIDs = uniqueNonEmptyStrings(conversationIDs) if len(conversationIDs) == 0 { return callMCPToolOnServer(serverID, toolName, toolArgs) } opts, err := readPagedCommandOptions(cmd) if err != nil { return err } if commandDryRun(cmd) { return writeConversationScopedSearchPreview(cmd, serverID, toolName, scopeParam, toolArgs, conversationIDs, opts) } if err := validateNativeSearchConversationScope(conversationIDs); err != nil { return err } // The downstream search currently treats invalid CID filters as absent and // does not reliably return group-scoped hits. Keep every other filter, scan // the global result stream with a hard page bound, and apply the validated // conversation set locally. scanArgs := cloneStringAnyMap(toolArgs) delete(scanArgs, scopeParam) pageSize := positiveSearchLimit(scanArgs["limit"], 100) resultLimit := pageSize pageLimit := maxConversationScopedSearchPages if opts.pageAll { resultLimit = opts.maxItems pageLimit = opts.pageLimit } cursor := cleanSearchCursor(scanArgs["cursor"]) capacity := resultLimit if capacity <= 0 { capacity = pageSize } messages := make([]map[string]any, 0, capacity) seenMessageIDs := map[string]struct{}{} pagesFetched := 0 hasMore := false truncatedWithinPage := false var nextCursor any for pagesFetched < pageLimit && (resultLimit == 0 || len(messages) < resultLimit) { requestLimit := pageSize if !opts.pageAll { requestLimit = resultLimit - len(messages) } scanArgs["limit"] = requestLimit scanArgs["cursor"] = cursor text, err := CallMCPToolTextOnServer(serverID, toolName, scanArgs) if err != nil { return err } var data map[string]any if err := unmarshalJSONUseNumber(text, &data); err != nil { return apperrors.NewInternal( fmt.Sprintf("解析 %s 返回失败: %v", toolName, err), apperrors.WithReason("search_response_invalid"), ) } pagesFetched++ pageMessages := chatmsg.SearchItems(data) pageMessages, unverifiableMessageIDs := chatmsg.FilterConversationScope(pageMessages, conversationIDs) if len(unverifiableMessageIDs) > 0 { return nativeSearchScopeUnverifiedError(conversationIDs, unverifiableMessageIDs) } for index, message := range pageMessages { messageID := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))) if messageID != "" && messageID != "" { if _, exists := seenMessageIDs[messageID]; exists { continue } seenMessageIDs[messageID] = struct{}{} } messages = append(messages, message) if resultLimit > 0 && len(messages) == resultLimit { truncatedWithinPage = index < len(pageMessages)-1 break } } page := chatmsg.Pagination(data) hasMoreValue, paginationKnown := page["hasMore"].(bool) if !paginationKnown { return apperrors.NewAPI( "搜索服务未返回可靠的 hasMore,无法安全完成会话范围过滤", apperrors.WithReason("search_conversation_scope_pagination_unknown"), apperrors.WithRetryable(false), ) } hasMore = hasMoreValue || truncatedWithinPage nextCursor = page["nextCursor"] if !hasMore { break } if truncatedWithinPage { break } next := cleanSearchCursor(nextCursor) if next == "" || next == cursor { return apperrors.NewAPI( "搜索服务声称仍有更多结果,但 nextCursor 缺失或未前进", apperrors.WithReason("search_conversation_scope_cursor_stalled"), apperrors.WithRetryable(false), ) } if resultLimit > 0 && len(messages) >= resultLimit { break } cursor = next if opts.pageAll && opts.delayMS > 0 { if err := sleepPagedCommandDelay(cmd.Context(), time.Duration(opts.delayMS)*time.Millisecond); err != nil { return err } } } result := map[string]any{ "conversationMessagesList": chatmsg.GroupSearchMessages(messages), "hasMore": hasMore, "complete": !hasMore, "pagesFetched": pagesFetched, } if hasMore && nextCursor != nil && cleanSearchCursor(nextCursor) != "" { result["nextCursor"] = nextCursor } payload := map[string]any{ "result": result, "scope": map[string]any{ "requestedConversationIds": append([]string(nil), conversationIDs...), "targetsValidated": true, "filterApplied": true, "filterMode": "client", "resultsWithinScope": true, "sourceComplete": !hasMore, }, } if opts.pageAll { paging := map[string]any{ "truncated": hasMore, "hasMore": hasMore, "lastCursor": nextCursor, "pages": pagesFetched, "total": len(messages), } if truncatedWithinPage { paging["truncatedWithinPage"] = true paging["resumeCursorReliable"] = false } payload["paging"] = paging } if projectResult != nil { payload = projectResult(payload) } return writeCommandPayload(cmd, payload) } func writeConversationScopedSearchPreview( cmd *cobra.Command, serverID, toolName, scopeParam string, toolArgs map[string]any, conversationIDs []string, opts pagedCommandOptions, ) error { plan := make([]map[string]any, 0, len(conversationIDs)+2) for _, conversationID := range conversationIDs { plan = append(plan, map[string]any{ "stage": "validate-conversation", "product": "chat", "tool": "get_conversation_info", "arguments": map[string]any{ "openConversationId": conversationID, }, }) } scanArgs := cloneStringAnyMap(toolArgs) delete(scanArgs, scopeParam) pageLimit := maxConversationScopedSearchPages maxItems := positiveSearchLimit(scanArgs["limit"], 100) pageDelay := 0 if opts.pageAll { pageLimit = opts.pageLimit maxItems = opts.maxItems pageDelay = opts.delayMS } plan = append(plan, map[string]any{ "stage": "search-global", "product": serverID, "tool": toolName, "arguments": scanArgs, "pageAll": opts.pageAll, "pageLimit": pageLimit, "maxItems": maxItems, "pageDelay": pageDelay, }, map[string]any{ "stage": "filter-conversation-scope", "requestedConversationIds": append([]string(nil), conversationIDs...), "failClosed": true, }, ) return writeCommandPayload(cmd, map[string]any{ "dry_run": true, "executed": false, "plan": plan, }) } func validateNativeSearchConversationScope(conversationIDs []string) error { for _, conversationID := range conversationIDs { _, err := CallMCPToolTextOnServer("chat", "get_conversation_info", map[string]any{ "openConversationId": conversationID, }) if err == nil { continue } return NormalizeSearchConversationScopeError(conversationID, err) } return nil } func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour) if err != nil { return nil, err } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ "startTime": startRaw, "endTime": endRaw, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, }, nil } func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { startRaw := mustGetFlag(cmd, "start") endRaw := mustGetFlag(cmd, "end") anchor := time.Now() if endRaw == "" { endRaw = formatChatMessageListAllTime(anchor.UnixMilli()) } else if startRaw == "" { endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return "", "", err } anchor = time.UnixMilli(endMs) } if startRaw == "" { startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli()) } return startRaw, endRaw, nil } func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") if senderUserID != "" && senderOpenDingTalkID != "" { return nil, fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one") } if senderUserID == "" && senderOpenDingTalkID == "" { return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") } if senderOpenDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil { return nil, err } } startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } cursor, _ := cmd.Flags().GetString("cursor") toolArgs := map[string]any{ "startTime": startMs, "endTime": endMs, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, } if senderUserID != "" { toolArgs["senderUserId"] = senderUserID } else { toolArgs["senderOpenDingTalkId"] = senderOpenDingTalkID } return toolArgs, nil } func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) { startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } cursor, _ := cmd.Flags().GetString("cursor") toolArgs := map[string]any{ "startTime": startMs, "endTime": endMs, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, } if groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat"); groupID != "" { toolArgs["openConversationId"] = groupID } return toolArgs, nil } func chatMessageListFocusedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 { toolArgs["cursor"] = v } return toolArgs, nil } func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { return nil, err } startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } cursor, _ := cmd.Flags().GetString("cursor") toolArgs := map[string]any{ "keyword": flagOrFallback(cmd, "query", "keyword"), "startTime": startMs, "endTime": endMs, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, } if groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat"); groupID != "" { toolArgs["openConversationId"] = groupID } return toolArgs, nil } func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { startRaw := mustGetFlag(cmd, "start") endRaw := mustGetFlag(cmd, "end") anchor := time.Now() if endRaw == "" { endRaw = anchor.Format(time.RFC3339) } else if startRaw == "" { endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return "", "", err } anchor = time.UnixMilli(endMs) } if startRaw == "" { startRaw = anchor.Add(-lookback).Format(time.RFC3339) } return startRaw, endRaw, nil } func formatChatMessageListAllTime(ms int64) string { return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05") } func defaultChatMessageListTime() string { return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05") } func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v := flagOrFallback(cmd, "query", "keyword"); v != "" { toolArgs["keyword"] = v } if v := flagOrFallback(cmd, "users", "user", "userId"); v != "" { appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") } if v := flagOrFallback(cmd, "sender-ids", "senders", "sender"); v != "" { appendChatIDArgs(toolArgs, parseCSVValues(v), "senderUserIds", "senderOpenDingTakIds") } if v, _ := cmd.Flags().GetBool("at-me"); v { toolArgs["atMe"] = true } if v, _ := cmd.Flags().GetString("at-ids"); v != "" { appendChatIDArgs(toolArgs, parseCSVValues(v), "atUserIds", "atOpenDingTakIds") } appendChatConversationIDs(cmd, toolArgs) if err := appendChatAdvancedFilters(cmd, toolArgs); err != nil { return nil, err } return toolArgs, nil } func appendChatConversationIDs(cmd *cobra.Command, toolArgs map[string]any) { convIds := flagOrFallback(cmd, "conversation-ids", "groups", "group") if convIds == "" { return } var ids []string for _, s := range strings.Split(convIds, ",") { if t := strings.TrimSpace(s); t != "" { ids = append(ids, t) } } if len(ids) > 0 { toolArgs["openConversationIds"] = ids } } func appendChatAdvancedFilters(cmd *cobra.Command, toolArgs map[string]any) error { if v, _ := cmd.Flags().GetString("message-type"); v != "" { toolArgs["messageType"] = v } if cmd.Flags().Changed("only-robot") { toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot") } else if cmd.Flags().Changed("only-robot-messages") { toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages") } if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" { toolArgs["searchConvType"] = v } if v, _ := cmd.Flags().GetString("start"); v != "" { ms, err := parseISOTimeToMillis("start", v) if err != nil { return err } toolArgs["startTime"] = ms } if v, _ := cmd.Flags().GetString("end"); v != "" { ms, err := parseISOTimeToMillis("end", v) if err != nil { return err } toolArgs["endTime"] = ms } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v } return nil } func nativeSearchScopeUnverifiedError(conversationIDs, messageIDs []string) error { return apperrors.NewAPI( "搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出", apperrors.WithReason("search_conversation_scope_unverified"), apperrors.WithDetails(map[string]any{ "requestedConversationIds": conversationIDs, "unverifiableMessageIds": messageIDs, }), apperrors.WithRetryable(false), apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"), ) } func uniqueNonEmptyStrings(values []string) []string { out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return out } func positiveSearchLimit(value any, fallback int) int { switch typed := value.(type) { case int: if typed > 0 { return typed } case int64: if typed > 0 { return int(typed) } case json.Number: if parsed, err := strconv.Atoi(typed.String()); err == nil && parsed > 0 { return parsed } case float64: if typed > 0 { return int(typed) } } return fallback } func cleanSearchCursor(value any) string { if value == nil { return "" } text := strings.TrimSpace(fmt.Sprint(value)) if text == "" || strings.EqualFold(text, "null") { return "" } return text } func chatMessageListFavoritesArgs(cmd *cobra.Command) (map[string]any, error) { cursor, _ := cmd.Flags().GetInt64("cursor") if cursor < 0 { return nil, apperrors.NewValidation("--cursor must be greater than or equal to 0") } size, _ := cmd.Flags().GetInt("size") if size < 1 || size > chatFavoritesMaxPageSize { return nil, apperrors.NewValidation("--size must be between 1 and 30") } return map[string]any{"cursor": cursor, "size": strconv.Itoa(size)}, nil } func pagedChatMessagesConfig(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { return PagedMCPCommandConfig{ ServerID: "chat", ToolName: toolName, ItemPath: "result.messages", CursorPath: "result.nextCursor", HasMorePath: "result.hasMore", CursorArg: "cursor", CursorKind: PagedCursorString, BuildArgs: build, Fallback: func(args map[string]any) error { return callMCPTool(toolName, args) }, } } func pagedChatConversationMessagesConfig(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { cfg := pagedChatMessagesConfig(toolName, build) cfg.ItemPath = "result.conversationMessagesList" cfg.AggregationMode = PagedAggregationConversationMessages return cfg } func pagedProjectedChatSearchConfig(cmd *cobra.Command, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { cfg := pagedChatConversationMessagesConfig(toolName, build) cfg.Fallback = func(args map[string]any) error { return callProjectedChatMessages(cmd, toolName, args, true) } cfg.ProjectResult = func(payload map[string]any) map[string]any { return projectChatMessagesPayload(payload, true) } return cfg } func pagedChatConversationMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { cfg := pagedChatConversationMessagesConfig(toolName, build) cfg.ServerID = serverID cfg.Fallback = func(args map[string]any) error { return callMCPToolOnServer(serverID, toolName, args) } return cfg } func pagedChatMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { cfg := pagedChatMessagesConfig(toolName, build) cfg.ServerID = serverID cfg.Fallback = func(args map[string]any) error { return callMCPToolOnServer(serverID, toolName, args) } return cfg } func pagedChatMessagesInt64Config(toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig { cfg := pagedChatMessagesConfig(toolName, build) cfg.CursorKind = PagedCursorInt64 return cfg } func pagedMCPParamDecls() []contract.ParamDecl { return []contract.ParamDecl{ {Name: "page-all", InterfaceType: "boolean"}, {Name: "page-limit", InterfaceType: "integer"}, {Name: "max-items", InterfaceType: "integer"}, {Name: "page-delay", InterfaceType: "integer"}, } } func runChatGroupSearch(cmd *cobra.Command, args []string) error { keyword := flagOrFallback(cmd, "query", "keyword", "name", "group") if len(args) == 1 { if keyword != "" { return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定") } keyword = strings.TrimSpace(args[0]) } if keyword == "" { return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"") } limit := chatIntFlagOrFallback(cmd, "limit", "size") cursor, _ := cmd.Flags().GetString("cursor") toolArgs := map[string]any{ "keyword": keyword, "limit": limit, "cursor": cursor, } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPToolOnServer("im", "search_groups", toolArgs) } func newChatGroupSearchCommand(hidden bool) *cobra.Command { cmd := &cobra.Command{ Use: "search [query]", Short: "根据关键词搜索群聊", Hidden: hidden, Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 注意: 1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。 2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`, Example: ` dws chat search --query "项目冲刺" dws chat search "项目冲刺" dws chat search --query "项目冲刺" --limit 20 --cursor 0`, Args: cobra.MaximumNArgs(1), RunE: runChatGroupSearch, } cmd.Flags().String("query", "", "搜索关键词 (必填)") cmd.Flags().String("keyword", "", "--query 的别名") _ = cmd.Flags().MarkHidden("keyword") cmd.Flags().String("name", "", "--query 的兼容别名") _ = cmd.Flags().MarkHidden("name") cmd.Flags().String("group", "", "--query 的兼容别名") _ = cmd.Flags().MarkHidden("group") cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") cmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = cmd.Flags().MarkHidden("size") cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") return cmd } func runChatSearchCommon(cmd *cobra.Command, _ []string) error { if err := validateRequiredFlags(cmd, "nicks"); err != nil { return err } nicks := parseCSVValues(mustGetFlag(cmd, "nicks")) limit := chatIntFlagOrFallback(cmd, "limit", "size") cursor, _ := cmd.Flags().GetString("cursor") matchMode, _ := cmd.Flags().GetString("match-mode") toolArgs := map[string]any{ "nicks": nicks, "matchMode": matchMode, "limit": limit, "cursor": cursor, } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPTool("search_common_groups", toolArgs) } // sanitizeTitleFromText derives a safe title from message text. // When the user doesn't provide --title, the text content (which may contain // URLs with percent-encoded characters like %3D, %26) is used as title. // The title field has stricter validation on the server side (128 bytes max), // so we strip or truncate content that is likely to be rejected. func sanitizeTitleFromText(text string) (title string) { const maxTitleBytes = 100 const maxTitleRunes = 30 // conservative: 30 CJK chars = 90 bytes, leaving room for "..." const fallbackTitle = "消息" if strings.TrimSpace(text) == "" { return fallbackTitle } // If text contains a URL, use only the portion before the URL as title. for _, prefix := range []string{"https://", "http://"} { if idx := strings.Index(text, prefix); idx > 0 { candidate := strings.TrimSpace(text[:idx]) if candidate != "" { return truncateTitleToBytes(candidate, maxTitleBytes, maxTitleRunes) } } } // If the entire text is a URL, use a generic title. if strings.HasPrefix(text, "https://") || strings.HasPrefix(text, "http://") { return fallbackTitle } // Final fallback: if the result contains percent-encoded sequences // that the server may reject, use a generic title. if strings.Contains(text, "%") { return fallbackTitle } // No URL — truncate to safe length. return truncateTitleToBytes(text, maxTitleBytes, maxTitleRunes) } // truncateTitleToBytes truncates a title string ensuring it doesn't exceed // maxBytes in UTF-8 encoding. It also limits by rune count for readability. func truncateTitleToBytes(s string, maxBytes, maxRunes int) string { runes := []rune(s) if len(runes) > maxRunes { runes = runes[:maxRunes] } result := string(runes) // Ensure we don't exceed byte limit for len(result) > maxBytes-3 { // reserve 3 bytes for "..." runes = runes[:len(runes)-1] result = string(runes) } if len([]rune(s)) > len(runes) { return result + "..." } return result } // marshalJSONRaw serializes v to JSON without escaping <, >, &. func marshalJSONRaw(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, err } // Encode appends a trailing newline; trim it. b := buf.Bytes() if len(b) > 0 && b[len(b)-1] == '\n' { b = b[:len(b)-1] } return b, nil } // parseCSVInt64 parses a comma-separated string of integers into []int64. func parseCSVInt64(raw string) ([]int64, error) { parts := strings.Split(raw, ",") result := make([]int64, 0, len(parts)) for _, p := range parts { v := strings.TrimSpace(p) if v == "" { continue } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return nil, fmt.Errorf("invalid integer %q", v) } result = append(result, n) } if len(result) == 0 { return nil, fmt.Errorf("at least one ID is required") } return result, nil } func isNumericUserID(value string) bool { value = strings.TrimSpace(value) if value == "" { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true } func isOpenDingTalkID(value string) bool { return targetresolver.LooksLikeCurrentDOpenDingTalkID(value) } // webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。 // webhook 失败时 HTTP 仍是 200 且返回 {errcode!=0, errmsg},需据 errcode 显式识别。 // errcode 可能是 JSON 数字或字符串,统一转字符串比较;缺 errcode 或为 0 视为成功。 func webhookErrcodeFailure(raw string) (code, msg string, failed bool) { var m map[string]any if json.Unmarshal([]byte(raw), &m) != nil { return "", "", false } ec, ok := m["errcode"] if !ok { return "", "", false } code = strings.TrimSpace(fmt.Sprintf("%v", ec)) if code == "" || code == "0" || code == "0.0" { return "", "", false } if v, ok := m["errmsg"].(string); ok { msg = v } return code, msg, true } func splitChatIDValues(values []string) (userIDs []string, openDingTalkIDs []string) { for _, raw := range values { value := strings.TrimSpace(raw) if value == "" { continue } if isOpenDingTalkID(value) { openDingTalkIDs = append(openDingTalkIDs, value) } else { userIDs = append(userIDs, value) } } return userIDs, openDingTalkIDs } func appendStringSliceArg(args map[string]any, key string, values []string) { if len(values) == 0 { return } if existing, ok := args[key].([]string); ok { args[key] = append(existing, values...) return } args[key] = values } func appendChatIDArgs(args map[string]any, values []string, userIDKey, openDingTalkIDKey string) bool { userIDs, openDingTalkIDs := splitChatIDValues(values) appendStringSliceArg(args, userIDKey, userIDs) appendStringSliceArg(args, openDingTalkIDKey, openDingTalkIDs) return len(userIDs) > 0 } // normalizeAtPlaceholders 统一文本中针对 ids 的 @ 占位符格式,消化 send 与 send-by-bot 之间的差异。 // wrapAngle=true:用户身份发消息(send),裸 @id 自动包成 <@id>;已有 <@id> 保持不变。 // wrapAngle=false:机器人发消息(send-by-bot),<@id> 自动剥离为 @id。 // 仅替换 ids 中实际声明的标识,避免误伤 markdown 中其他 <...> 内容。 func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string { const sentinelPrefix = "\x00DWS_AT_WRAPPED_" const sentinelSuffix = "\x00" for _, raw := range ids { id := strings.TrimSpace(raw) if id == "" { continue } wrapped := "<@" + id + ">" bare := "@" + id if wrapAngle { sentinel := sentinelPrefix + id + sentinelSuffix text = strings.ReplaceAll(text, wrapped, sentinel) text = strings.ReplaceAll(text, bare, wrapped) text = strings.ReplaceAll(text, sentinel, wrapped) } else { text = strings.ReplaceAll(text, wrapped, bare) } } return text } // NormalizeMessageMentions applies the placeholder convention required by the // selected sender identity and ensures a declared @all is present in the body. // Current-user messages use <@id>/<@all>; bot and webhook messages use // @id/@mobile/@all. func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string { text = normalizeAtPlaceholders(text, ids, wrapAngle) allPlaceholder := "@all" if wrapAngle { text = normalizeAtPlaceholders(text, []string{"all"}, true) allPlaceholder = "<@all>" } else { text = strings.ReplaceAll(text, "<@all>", "@all") } if atAll && !containsMessageMention(text, allPlaceholder) { text = allPlaceholder + " " + text } return text } // applyCurrentUserGroupMentions keeps the body placeholders and // send_personal_message mention arguments aligned for send and reply. func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string { var atOpenIDs []string if rawOpenIDs != "" { atOpenIDs = strings.Split(rawOpenIDs, ",") } if atAll && !strings.Contains(text, "<@all>") { text = "<@all> " + text } text = normalizeAtPlaceholders(text, atOpenIDs, true) if atAll { params["atAll"] = true } if len(atOpenIDs) > 0 { params["atOpenDingTalkIds"] = atOpenIDs } return text } func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string { if rawOpenIDs == "" { return text } missing := make([]string, 0) probeText := text for _, id := range parseCSVValues(rawOpenIDs) { placeholder := "<@" + id + ">" if strings.Contains(probeText, placeholder) { continue } missing = append(missing, placeholder) probeText += placeholder } if len(missing) == 0 { return text } prefix := strings.Join(missing, " ") if strings.HasPrefix(text, "<@all> ") { return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ") } return prefix + " " + text } func containsMessageMention(text, placeholder string) bool { if strings.HasPrefix(placeholder, "<") { return strings.Contains(text, placeholder) } for searchFrom := 0; ; { offset := strings.Index(text[searchFrom:], placeholder) if offset < 0 { return false } end := searchFrom + offset + len(placeholder) if end == len(text) { return true } next, _ := utf8.DecodeRuneInString(text[end:]) if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' { return true } searchFrom = end } } func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) { ids, err := resolveOpenDingTalkIDs(ctx, []string{value}) if err != nil { return "", err } if len(ids) == 0 || ids[0] == "" { return "", fmt.Errorf("empty user identifier") } return ids[0], nil } func resolveOpenDingTalkIDs(ctx context.Context, values []string) ([]string, error) { resolved := make([]string, len(values)) userIDs := make([]string, 0, len(values)) userIDIndexes := make([]int, 0, len(values)) seenUserIDs := make(map[string]bool) for i, raw := range values { value := strings.TrimSpace(raw) if value == "" { continue } if isOpenDingTalkID(value) { resolved[i] = value } else { userIDIndexes = append(userIDIndexes, i) if !seenUserIDs[value] { seenUserIDs[value] = true userIDs = append(userIDs, value) } } } if len(userIDs) == 0 { return resolved, nil } openByUserID, err := lookupOpenDingTalkIDsByUserID(ctx, userIDs) if err != nil { return nil, err } for _, index := range userIDIndexes { userID := strings.TrimSpace(values[index]) openDingTalkID := openByUserID[userID] if openDingTalkID == "" { return nil, fmt.Errorf("cannot resolve userId %q to openDingTalkId; hint: run dws contact user search --keyword \"姓名\" --format json and pass the returned openDingTalkId", userID) } resolved[index] = openDingTalkID } return resolved, nil } // groupOwnerOpenDingTalkID 分页拉取群成员,返回群主(memberRoleType==1)的 openDingtalkId。 // 找不到群主或群主字段为空时返回 ""(由调用方决定是否放行)。 func groupOwnerOpenDingTalkID(ctx context.Context, openConversationID string) (string, error) { cursor := "0" for page := 0; page < 50; page++ { // 防御性上限,避免异常分页导致死循环 raw, err := callMCPToolReturnText(ctx, "get_group_members", map[string]any{ "openconversation_id": openConversationID, "cursor": cursor, }) if err != nil { return "", err } var body struct { Result struct { HasMore bool `json:"hasMore"` NextCursor string `json:"nextCursor"` Cursor string `json:"cursor"` List []struct { MemberRoleType int `json:"memberRoleType"` OpenDingtalkID string `json:"openDingtalkId"` } `json:"list"` } `json:"result"` } if err := json.Unmarshal([]byte(raw), &body); err != nil { return "", err } for _, m := range body.Result.List { if m.MemberRoleType == 1 { return m.OpenDingtalkID, nil } } next := body.Result.NextCursor if next == "" { next = body.Result.Cursor } if !body.Result.HasMore || next == "" || next == cursor { break } cursor = next } return "", nil } // guardGroupOwnerRemoval 拦截把群主移出群的操作:一个群只有一个群主,移出群主会产生 // 无群主的“孤儿群”。防护为尽力而为:群主信息查询失败或 userId→openDingTalkId 解析 // 失败时不阻塞正常移除路径(由服务端兜底)。 func guardGroupOwnerRemoval(ctx context.Context, openConversationID string, removeValues []string) error { ownerOpenID, err := groupOwnerOpenDingTalkID(ctx, openConversationID) if err != nil || ownerOpenID == "" { return nil } ownerErr := fmt.Errorf( "refusing to remove the group owner: 被移除列表包含群主,移出群主将导致群无群主(孤儿群)\n hint: 先执行 dws chat group transfer-owner --group %s --user 转让群主后再移除", openConversationID, ) userIDs, openDingTalkIDs := splitChatIDValues(removeValues) for _, id := range openDingTalkIDs { if id == ownerOpenID { return ownerErr } } if len(userIDs) > 0 { if resolved, resolveErr := resolveOpenDingTalkIDs(ctx, userIDs); resolveErr == nil { for _, id := range resolved { if id == ownerOpenID { return ownerErr } } } } return nil } func lookupOpenDingTalkIDsByUserID(ctx context.Context, userIDs []string) (map[string]string, error) { mapping := map[string]string{} namesByUserID := map[string]string{} // Step 1: Try contact service to get openDingTalkId and username by userId. raw, err := callMCPToolReturnTextOnServer(ctx, "contact", "get_user_info_by_user_ids", map[string]any{ "user_id_list": userIDs, }) if err == nil { var body any dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() if err := dec.Decode(&body); err != nil { return nil, fmt.Errorf("failed to parse contact user get response: %w", err) } collectContactUserMappings(body, mapping, namesByUserID) } if allUserIDsMapped(userIDs, mapping) { return mapping, nil } // Step 2: For unmapped userIds, use aisearch with username (not userId) as keyword. for _, userID := range userIDs { if userID == "" || mapping[userID] != "" { continue } keyword := namesByUserID[userID] if keyword == "" { // No username resolved from contact; fall through to keyword search below. continue } _ = lookupOpenDingTalkIDsByAisearchPerson(ctx, keyword, mapping, namesByUserID) } if allUserIDsMapped(userIDs, mapping) { return mapping, nil } // Step 3: Fallback - search contact by username or userId keyword. for _, userID := range userIDs { if mapping[userID] != "" { continue } keywords := []string{} if name := namesByUserID[userID]; name != "" { keywords = append(keywords, name) } keywords = append(keywords, userID) searched := map[string]bool{} for _, keyword := range keywords { if keyword == "" || searched[keyword] { continue } searched[keyword] = true searchRaw, err := callMCPToolReturnTextOnServer(ctx, "contact", "search_contact_by_key_word", map[string]any{ "keyword": keyword, }) if err != nil { return nil, err } var searchBody any searchDec := json.NewDecoder(strings.NewReader(searchRaw)) searchDec.UseNumber() if err := searchDec.Decode(&searchBody); err != nil { return nil, fmt.Errorf("failed to parse contact user search response: %w", err) } collectContactUserMappings(searchBody, mapping, namesByUserID) if mapping[userID] != "" { break } } } return mapping, nil } func allUserIDsMapped(userIDs []string, mapping map[string]string) bool { for _, userID := range userIDs { if strings.TrimSpace(userID) != "" && mapping[userID] == "" { return false } } return true } func lookupOpenDingTalkIDsByAisearchPerson(ctx context.Context, keyword string, openByUserID map[string]string, nameByUserID map[string]string) error { raw, err := callMCPToolReturnTextOnServer(ctx, "aisearch", "enterprise_person_search", map[string]any{ "keyword": keyword, "dimension": []string{"all"}, }) if err != nil { return err } var body any dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() if err := dec.Decode(&body); err != nil { return fmt.Errorf("failed to parse aisearch person response: %w", err) } collectContactUserMappings(body, openByUserID, nameByUserID) return nil } var ( chatUserIDJSONKeys = map[string]bool{ "userid": true, "user_id": true, "orguserid": true, "org_user_id": true, "uid": true, "staffid": true, "staff_id": true, } chatOpenDingTalkIDJSONKeys = map[string]bool{ "opendingtalkid": true, "opendingtalk_id": true, "open_dingtalk_id": true, "opendingid": true, } chatUserNameJSONKeys = map[string]bool{ "name": true, "username": true, "user_name": true, "orgusername": true, "org_user_name": true, "displayname": true, "display_name": true, "nick": true, } chatContactNestedUserKeys = []string{"orgEmployeeModel", "employee", "user", "profile", "staff"} ) func collectContactUserMappings(value any, openByUserID map[string]string, nameByUserID map[string]string) { switch typed := value.(type) { case []any: for _, item := range typed { collectContactUserMappings(item, openByUserID, nameByUserID) } case map[string]any: userID := stringForJSONKeys(typed, chatUserIDJSONKeys) openDingTalkID := stringForJSONKeys(typed, chatOpenDingTalkIDJSONKeys) userName := stringForJSONKeys(typed, chatUserNameJSONKeys) if userID == "" { userID = stringForNestedJSONKeys(typed, chatContactNestedUserKeys, chatUserIDJSONKeys) } if openDingTalkID == "" { openDingTalkID = stringForNestedJSONKeys(typed, chatContactNestedUserKeys, chatOpenDingTalkIDJSONKeys) } if userName == "" { userName = stringForNestedJSONKeys(typed, chatContactNestedUserKeys, chatUserNameJSONKeys) } if userID != "" && openDingTalkID != "" { openByUserID[userID] = openDingTalkID } if userID != "" && userName != "" { nameByUserID[userID] = userName } for _, item := range typed { collectContactUserMappings(item, openByUserID, nameByUserID) } } } func stringForNestedJSONKeys(value map[string]any, nestedKeys []string, keys map[string]bool) string { for _, nestedKey := range nestedKeys { if nested, ok := value[nestedKey].(map[string]any); ok { if found := stringForJSONKeys(nested, keys); found != "" { return found } } } return "" } func stringForJSONKeys(value map[string]any, keys map[string]bool) string { for key, raw := range value { if !keys[strings.ToLower(key)] { continue } if str := stringFromJSONScalar(raw); str != "" { return str } } return "" } func stringFromJSONScalar(value any) string { switch typed := value.(type) { case string: return strings.TrimSpace(typed) case json.Number: return typed.String() case float64: return strconv.FormatFloat(typed, 'f', -1, 64) case float32: return strconv.FormatFloat(float64(typed), 'f', -1, 32) case int: return strconv.Itoa(typed) case int64: return strconv.FormatInt(typed, 10) case int32: return strconv.FormatInt(int64(typed), 10) default: return "" } } func buildConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") rawOpenDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") rawUserID := flagOrFallback(cmd, "user", "userId") // All three params are passed through to the server; the server decides which to use. userID := rawUserID openDingTalkID := rawOpenDingTalkID if openDingTalkID != "" && isNumericUserID(openDingTalkID) { userID = openDingTalkID openDingTalkID = "" } if userID != "" && !isNumericUserID(userID) { openDingTalkID = userID userID = "" } toolArgs := map[string]any{} if groupID != "" { toolArgs["openConversationId"] = groupID } if userID != "" { toolArgs["userId"] = userID } if openDingTalkID != "" { toolArgs["openDingTalkId"] = openDingTalkID } return toolArgs, nil } var chatValidGrantTypes = map[string]bool{ "once": true, "session": true, "timed": true, "permanent": true, } func validateChatScope(scope string) error { if !strings.HasPrefix(scope, "chat.") { return fmt.Errorf("invalid scope %q, dws chat chmod only accepts chat.* scope", scope) } return nil } func buildChatGrantBaseArgs(cmd *cobra.Command, scope string) (map[string]any, error) { grantType, _ := cmd.Flags().GetString("grant-type") if !chatValidGrantTypes[grantType] { return nil, fmt.Errorf("invalid --grant-type %q, must be one of: once, session, timed, permanent", grantType) } ttl, _ := cmd.Flags().GetString("ttl") sessionID, _ := cmd.Flags().GetString("session-id") if grantType == "timed" && ttl == "" { return nil, fmt.Errorf("--ttl is required when --grant-type is timed") } if grantType == "session" && sessionID == "" { return nil, fmt.Errorf("--session-id is required when --grant-type is session") } toolArgs := map[string]any{ "agentCode": mustGetFlag(cmd, "agentCode"), "scope": scope, "grantType": grantType, } if grantType == "timed" { toolArgs["ttl"] = ttl } if sessionID != "" { toolArgs["sessionId"] = sessionID } return toolArgs, nil } func buildChatChmodArgs(cmd *cobra.Command, scope string) (map[string]any, error) { toolArgs, err := buildChatGrantBaseArgs(cmd, scope) if err != nil { return nil, err } if err := appendChatChmodParams(cmd, toolArgs); err != nil { return nil, err } return toolArgs, nil } func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) { targetOrgID := strings.TrimSpace(mustGetFlag(cmd, "target-org-id")) all, _ := cmd.Flags().GetBool("all") if targetOrgID == "" && !all { return nil, fmt.Errorf("--target-org-id or --all is required") } if targetOrgID != "" && all { return nil, fmt.Errorf("--target-org-id and --all cannot be used together") } if all { targetOrgID = "*" } toolArgs, err := buildChatGrantBaseArgs(cmd, "chat.data:cross-org") if err != nil { return nil, err } toolArgs["grantCategory"] = "data" paramsJSON, _ := marshalJSONRaw(map[string]string{"targetOrgId": targetOrgID}) toolArgs["grantParams"] = string(paramsJSON) return toolArgs, nil } func buildChatGroupShareInviteArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlags(cmd, "source"); err != nil { return nil, err } target, _ := cmd.Flags().GetString("target") receiver, _ := cmd.Flags().GetString("receiver") if target == "" && receiver == "" { return nil, fmt.Errorf("--target or --receiver is required") } if target != "" && receiver != "" { return nil, fmt.Errorf("--target and --receiver are mutually exclusive") } toolArgs := map[string]any{ "sourceOpenConversationId": mustGetFlag(cmd, "source"), } if target != "" { toolArgs["targetOpenConversationId"] = target } if receiver != "" { toolArgs["receiverOpenDingTalkId"] = receiver } if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") { toolArgs["expiresSeconds"] = v } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v } return toolArgs, nil } func appendChatChmodParams(cmd *cobra.Command, toolArgs map[string]any) error { conversationID, _ := cmd.Flags().GetString("conversation-id") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") userID, _ := cmd.Flags().GetString("user") rawParams, _ := cmd.Flags().GetStringArray("permParam") grantParams, err := parseChatChmodParams(rawParams) if err != nil { return err } specified := 0 for _, value := range []string{conversationID, openDingTalkID, userID} { if strings.TrimSpace(value) != "" { specified++ } } if specified > 1 { return fmt.Errorf("--conversation-id, --open-dingtalk-id and --user are mutually exclusive") } if conversationID != "" { putChatChmodParam(grantParams, "conversationId", conversationID) putChatChmodParam(grantParams, "openConversationId", conversationID) putChatChmodParam(grantParams, "openCid", conversationID) } else if openDingTalkID != "" { putChatChmodParam(grantParams, "openDingTalkId", openDingTalkID) } else { if userID != "" { putChatChmodParam(grantParams, "targetUid", userID) putChatChmodParam(grantParams, "receiverUid", userID) } } if specified == 0 && len(grantParams) == 0 { return fmt.Errorf("--conversation-id, --open-dingtalk-id, --user or --permParam is required") } paramsJSON, _ := marshalJSONRaw(grantParams) toolArgs["grantParams"] = string(paramsJSON) return nil } func parseChatChmodParams(values []string) (map[string]string, error) { params := map[string]string{} for _, raw := range values { item := strings.TrimSpace(raw) if item == "" { continue } parts := strings.SplitN(item, "=", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { return nil, fmt.Errorf("--permParam must be key=value, got %q", raw) } params[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } return params, nil } func putChatChmodParam(params map[string]string, key, value string) { if strings.TrimSpace(value) == "" { return } if _, exists := params[key]; !exists { params[key] = strings.TrimSpace(value) } } func fileMD5Hex(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("failed to open file for md5: %w", err) } defer file.Close() hash := md5.New() if _, err := io.Copy(hash, file); err != nil { return "", fmt.Errorf("failed to calculate md5: %w", err) } return hex.EncodeToString(hash.Sum(nil)), nil } func parseConversationFileUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) { var data map[string]any if err = unmarshalJSONUseNumber(text, &data); err != nil { return "", "", nil, fmt.Errorf("failed to parse upload credentials JSON: %w", err) } if result, ok := data["result"].(map[string]any); ok { data = result } resourceURL = firstStringField(data, "resourceUrl", "resourceURL", "url") if resourceURL == "" { if values, ok := data["resourceUrls"].([]any); ok && len(values) > 0 { resourceURL = stringFromJSONScalar(values[0]) } } uploadKey = firstStringField(data, "uploadKey", "key") if resourceURL == "" || uploadKey == "" { return "", "", nil, fmt.Errorf("incomplete upload credentials: resourceUrl=%q, uploadKey=%q", resourceURL, uploadKey) } headers = map[string]string{} for _, key := range []string{"headers", "ossHeaders"} { if h, ok := data[key].(map[string]any); ok { for name, value := range h { if s := stringFromJSONScalar(value); s != "" { headers[name] = s } } } } return resourceURL, uploadKey, headers, nil } type conversationLocalFileMeta struct { LocalPath string FileName string FileType string ContentPath string FileSize int64 MD5 string } var chatFileMD5 = fileMD5Hex func buildConversationLocalFileMeta(filePath, fileName, md5Value string) (conversationLocalFileMeta, error) { fi, err := os.Stat(filePath) if err != nil { return conversationLocalFileMeta{}, fmt.Errorf("cannot read file %s: %w", filePath, err) } if fi.IsDir() { return conversationLocalFileMeta{}, fmt.Errorf("%s is a directory, not a file", filePath) } if fileName == "" { fileName = filepath.Base(filePath) } fileType := strings.TrimPrefix(filepath.Ext(fileName), ".") if md5Value == "" { md5Value, err = chatFileMD5(filePath) if err != nil { return conversationLocalFileMeta{}, err } } return conversationLocalFileMeta{ LocalPath: filePath, FileName: fileName, FileType: fileType, ContentPath: "/" + fileName, FileSize: fi.Size(), MD5: md5Value, }, nil } func uploadConversationLocalFile(ctx context.Context, targetArgs map[string]any, meta conversationLocalFileMeta, uuid string) (string, error) { initArgs := cloneStringAnyMap(targetArgs) initArgs["fileName"] = meta.FileName initArgs["fileSize"] = meta.FileSize initArgs["md5"] = meta.MD5 if uuid != "" { initArgs["uuid"] = uuid } initText, err := callMCPToolReturnTextOnServer(ctx, "im", "init_conversation_file_upload", initArgs) if err != nil { return "", err } resourceURL, uploadKey, headers, err := parseConversationFileUploadInfo(initText) if err != nil { return "", err } if err := httpPutFile(ctx, resourceURL, headers, meta.LocalPath, meta.FileSize); err != nil { return "", err } commitArgs := cloneStringAnyMap(targetArgs) commitArgs["uploadKey"] = uploadKey commitArgs["fileName"] = meta.FileName commitArgs["fileSize"] = meta.FileSize commitArgs["md5"] = meta.MD5 if uuid != "" { commitArgs["uuid"] = uuid } return callMCPToolReturnTextOnServer(ctx, "im", "commit_conversation_file_upload", commitArgs) } func parseConversationFileDownloadURL(text string) (string, error) { var data map[string]any if err := unmarshalJSONUseNumber(text, &data); err != nil { return "", fmt.Errorf("failed to parse uploaded file response JSON: %w", err) } if result, ok := data["result"].(map[string]any); ok { data = result } downloadURL := firstStringField(data, "downloadUrl") if downloadURL == "" { return "", fmt.Errorf("uploaded file response missing downloadUrl") } return downloadURL, nil } func uploadRobotMessageLocalFile( cmd *cobra.Command, filePath string, groupID string, userIDs []string, openDingTalkIDs []string, ) (string, error) { uploadTarget := map[string]any{} if groupID != "" { uploadTarget["openConversationId"] = groupID } else { if len(userIDs)+len(openDingTalkIDs) != 1 { return "", fmt.Errorf("--file-path requires exactly one --users or --open-dingtalk-ids recipient") } if len(userIDs) == 1 { uploadTarget["userId"] = userIDs[0] } else { uploadTarget["openDingTalkId"] = openDingTalkIDs[0] } } meta, err := buildConversationLocalFileMeta(filePath, "", "") if err != nil { return "", err } if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "上传本地文件并由机器人发送") deps.Out.PrintKeyValue("文件", meta.LocalPath) deps.Out.PrintKeyValue("名称", meta.FileName) deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", meta.FileSize)) return "", nil } ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() commitText, err := uploadConversationLocalFile(ctx, uploadTarget, meta, "") if err != nil { return "", err } return parseConversationFileDownloadURL(commitText) } func buildConversationFileContent(dentryID, spaceID int64, meta conversationLocalFileMeta) (string, error) { content := struct { DentryID int64 `json:"dentryId"` SpaceID int64 `json:"spaceId"` FileName string `json:"fileName"` FileType string `json:"fileType"` FilePath string `json:"filePath"` FileSize int64 `json:"fileSize"` }{ DentryID: dentryID, SpaceID: spaceID, FileName: meta.FileName, FileType: meta.FileType, FilePath: meta.ContentPath, FileSize: meta.FileSize, } body, _ := marshalJSONRaw(content) return string(body), nil } func parseConversationFileSendIDs(text string) (int64, int64, error) { var data any if err := unmarshalJSONUseNumber(text, &data); err != nil { return 0, 0, fmt.Errorf("failed to parse uploaded file response JSON: %w", err) } dentryID, _ := findInt64Field(data, "dentryId", "dentryID") spaceID, _ := findInt64Field(data, "spaceId", "spaceID") if dentryID == 0 || spaceID == 0 { return 0, 0, fmt.Errorf("uploaded file response missing dentryId or spaceId") } return dentryID, spaceID, nil } func findInt64Field(value any, keys ...string) (int64, bool) { switch typed := value.(type) { case map[string]any: for _, key := range keys { if raw, ok := typed[key]; ok { if v, ok := int64FromJSONScalar(raw); ok { return v, true } } } for _, raw := range typed { if v, ok := findInt64Field(raw, keys...); ok { return v, true } } case []any: for _, raw := range typed { if v, ok := findInt64Field(raw, keys...); ok { return v, true } } case string: trimmed := strings.TrimSpace(typed) if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { var nested any if unmarshalJSONUseNumber(trimmed, &nested) == nil { return findInt64Field(nested, keys...) } } } return 0, false } func int64FromJSONScalar(value any) (int64, bool) { switch typed := value.(type) { case json.Number: v, err := typed.Int64() return v, err == nil case float64: return int64(typed), typed > 0 case int64: return typed, true case int: return int64(typed), true case string: v, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) return v, err == nil default: return 0, false } } func cloneStringAnyMap(src map[string]any) map[string]any { dst := make(map[string]any, len(src)) for key, value := range src { dst[key] = value } return dst } func unmarshalJSONUseNumber(text string, v any) error { dec := json.NewDecoder(strings.NewReader(text)) dec.UseNumber() return dec.Decode(v) } func nativeCardUpdateVerificationError(bizID string, verifyErr error) error { reason := "streaming_card_update_unverified" message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败" hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果" switch { case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied): reason = "streaming_card_update_not_applied" message = "服务端明确表示流式卡片没有被更新" hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换" case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift): reason = "streaming_card_update_biz_id_mismatch" message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新" hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射" } return apperrors.NewAPI( message, apperrors.WithOperation("update_streaming_card"), apperrors.WithServerKey("im"), apperrors.WithOrigin("client_postcondition"), apperrors.WithFailureStage("verify_update_result"), apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithReason(reason), apperrors.WithHint(hint), apperrors.WithDetails(map[string]any{"bizId": bizID}), apperrors.WithCause(verifyErr), ) } func firstStringField(data map[string]any, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { if s := stringFromJSONScalar(value); s != "" { return s } } } return "" } // ────────────────────────────────────────────────────────── // dws chat — 会话 / 群聊 / 消息 // ────────────────────────────────────────────────────────── func newChatCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/chat.json // products.chat). Catalog assembly stamps provenance contract_final. contract.RegisterProductDecl(contract.ProductDecl{ ID: "chat", Selection: contract.ProductSelectionDecl{ AgentSummary: "管理钉钉会话、群聊、群成员、机器人、消息检索与发送", UseWhen: []string{ "请求涉及群聊管理、聊天记录、消息发送、会话设置或群机器人", }, AvoidWhen: []string{ "实时监听未来 IM 事件用 event +listen-im;邮件用 mail;开放平台应用/机器人建号发布用 dev;企业语义找人优先 aisearch person", }, }, }) root := &cobra.Command{ Use: "chat", Aliases: []string{"im"}, Short: "群聊 / 消息 / 机器人", Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`, RunE: groupRunE, } chatChmodCmd := &cobra.Command{ Use: "chmod ", Short: "授予 chat 高风险操作权限", Long: `授予指定命令参数维度的 chat 操作权限。 该命令用于触发悟空宿主应用的授权确认弹窗。chat.* scope 每次执行都需要用户在宿主 UI 中确认,模型无法静默绕过。 授权维度: --permParam 授权原始业务参数,可重复传入 key=value,例如 --permParam openCid=xxx --permParam msgType=text 兼容目标选择(三选一): --conversation-id 群聊 openConversationId --open-dingtalk-id 单聊目标 openDingTalkId --user 单聊目标 userId,由服务端按 Diamond 映射解析为授权维度`, Example: ` dws chat chmod chat.message:send --agentCode agt-wukong-xxxx --grant-type timed --ttl 24h --permParam openCid=cidXXXXXXXXXX dws chat chmod chat.message:send --agentCode agt-wukong-xxxx --grant-type timed --ttl 24h --permParam receiverUid=123456 dws chat chmod chat.group:destroy --agentCode agt-wukong-xxxx --grant-type once --permParam openCid=cidXXXXXXXXXX dws chat chmod chat.message:send --agentCode agt-wukong-xxxx --grant-type timed --ttl 24h --conversation-id cidXXXXXXXXXX`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := validateChatScope(args[0]); err != nil { return err } toolArgs, err := buildChatChmodArgs(cmd, args[0]) if err != nil { return err } if !commandBoolFlag(cmd, "yes") { return apperrors.NewValidation( "授予 chat 高风险操作权限需要用户确认;获得用户确认后加 --yes 执行", apperrors.WithReason("confirmation_required"), apperrors.WithHint("先确认授权 scope、目标会话/用户和有效期;用户明确同意后以相同参数追加 --yes"), apperrors.WithActions("确认授权 scope 和影响范围", "获得用户确认后使用 --yes 执行"), ) } return callMCPToolOnServer("im", "chat_permission_grant", toolArgs) }, } chatChmodCmd.Flags().String("agentCode", "wukong", "Agent 标识,默认 wukong") chatChmodCmd.Flags().String("grant-type", "timed", "授权策略: once|session|timed|permanent") chatChmodCmd.Flags().String("ttl", "24h", "timed 授权有效期,如 1h/4h/24h/7d") chatChmodCmd.Flags().StringArray("permParam", nil, "授权原始业务参数,格式 key=value,可重复传入") chatChmodCmd.Flags().String("conversation-id", "", "群聊 openConversationId") chatChmodCmd.Flags().String("open-dingtalk-id", "", "单聊目标 openDingTalkId") chatChmodCmd.Flags().String("user", "", "单聊目标 userId(与 --open-dingtalk-id 二选一)") chatChmodCmd.Flags().String("session-id", "", "session 授权的会话标识") chatChmodCmd.Flags().BoolP("yes", "y", false, "确认执行 chat 高风险授权操作") DeclareLeafMetadata(chatChmodCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "chat_permission_grant", CanonicalPath: "chat.chat_permission_grant", CLIPath: "chat chmod", PrimaryCLIPath: "chat chmod", }, Description: "授予指定 chat scope 的高风险操作权限", Positionals: []contract.RuntimeSchemaPositional{ {Index: 0, Name: "scope", Type: "string", Required: true, Description: "chat 授权 scope,如 chat.message:send"}, }, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "chat_permission_grant"}, }, Selection: contract.SelectionSpec{ AgentSummary: "授予指定 chat scope 的高风险操作权限", UseWhen: []string{"需要在执行发送、撤回、群管理等 chat 高风险操作前申请授权时"}, AvoidWhen: []string{"只授予跨组织消息读取数据权限时使用 chat data-auth cross-org"}, Examples: []string{"dws chat chmod chat.message:send --agentCode wukong --grant-type timed --ttl 24h --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "agentCode", Property: "agentCode", Required: boolPtr(false)}, {Name: "conversation-id", Property: "grantParams.openCid", Required: boolPtr(false)}, {Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}}, {Name: "open-dingtalk-id", Property: "grantParams.openDingTalkId", Required: boolPtr(false)}, {Name: "permParam", Property: "grantParams", Required: boolPtr(false)}, {Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"}, {Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"}, {Name: "user", Property: "grantParams.userId", Required: boolPtr(false)}, }, }, }) chatDataAuthCmd := &cobra.Command{ Use: "data-auth", Short: "授予 chat 数据读取权限", Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`, RunE: groupRunE, } chatDataAuthCrossOrgCmd := &cobra.Command{ Use: "cross-org", Short: "授予跨组织 chat 数据访问权限", Long: `授予跨组织 chat 数据访问权限。 该命令调用与 dws chat chmod 相同的授权工具,但固定使用数据授权类别: scope: chat.data:cross-org grantCategory: data grantParams: {"targetOrgId":"<目标组织ID>"} 或 {"targetOrgId":"*"}`, Example: ` dws chat data-auth cross-org --target-org-id 439446171 dws chat data-auth cross-org --target-org-id 439446171 --agentCode wukong --grant-type timed --ttl 24h dws chat data-auth cross-org --all --grant-type timed --ttl 24h`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { toolArgs, err := buildChatCrossOrgDataAuthArgs(cmd) if err != nil { return err } return callMCPToolOnServer("im", "chat_permission_grant", toolArgs) }, } chatDataAuthCrossOrgCmd.Flags().String("target-org-id", "", "目标组织 ID(与 --all 二选一)") chatDataAuthCrossOrgCmd.Flags().Bool("all", false, "授权所有目标组织") chatDataAuthCrossOrgCmd.Flags().String("agentCode", "wukong", "Agent 标识,默认 wukong") chatDataAuthCrossOrgCmd.Flags().String("grant-type", "timed", "授权策略: once|session|timed|permanent") chatDataAuthCrossOrgCmd.Flags().String("ttl", "24h", "timed 授权有效期,如 1h/4h/24h/7d") chatDataAuthCrossOrgCmd.Flags().String("session-id", "", "session 授权的会话标识") chatDataAuthCrossOrgCmd.Flags().BoolP("yes", "y", false, "确认执行跨组织 chat 数据授权") DeclareLeafMetadata(chatDataAuthCrossOrgCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Validate: func(cmd *cobra.Command, args []string) error { _, err := buildChatCrossOrgDataAuthArgs(cmd) return err }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "chat_permission_grant_cross_org_data", CanonicalPath: "chat." + "chat_permission_grant_cross_org_data", CLIPath: "chat data-auth cross-org", PrimaryCLIPath: "chat data-auth cross-org", }, Description: "授予跨组织 chat 数据访问权限", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "chat_permission_grant"}, }, Selection: contract.SelectionSpec{ AgentSummary: "授予跨组织 chat 数据访问权限", UseWhen: []string{"需要为跨组织消息读取授予 chat.data:cross-org 数据权限时"}, AvoidWhen: []string{"发送、撤回或群管理授权应使用 chat chmod 指定操作 scope"}, Examples: []string{"dws chat data-auth cross-org --target-org-id 439446171"}, }, Parameters: []contract.ParamDecl{ {Name: "agentCode", Property: "agentCode", Required: boolPtr(false)}, {Name: "all", Property: "grantParams.targetOrgId", Required: boolPtr(false)}, {Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}}, {Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"}, {Name: "target-org-id", Property: "grantParams.targetOrgId", Required: boolPtr(false)}, {Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"}, }, }, }) chatDataAuthCmd.AddCommand(chatDataAuthCrossOrgCmd) // ── group 子命令 ────────────────────────────────────────── chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE} chatGroupCreateCmd := &cobra.Command{ Use: "create", Short: "创建群(支持内部群/外部群/普通群/话题圈)", Long: `创建一个群聊,支持指定群名称、初始成员列表等参数。 可选择内部群/外部群/普通群/内部话题圈/外部话题圈/普通话题圈等多种类型群。 默认创建内部群。当选择内部群时如果所选成员非组织内成员会创建失败。`, Example: ` dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 dws chat group create --name "外部合作群" --users userId1,userId2 --type EXTERNAL dws chat group create --name "话题圈" --users userId1,userId2 --thread # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "name", "users"); err != nil { return err } ctx := cmd.Context() // 校验 --type 取值 groupType, _ := cmd.Flags().GetString("type") groupType = strings.ToUpper(groupType) switch groupType { case "INTERNAL", "EXTERNAL", "NORMAL": // valid default: return fmt.Errorf("invalid --type %q, supported: INTERNAL, EXTERNAL, NORMAL", groupType) } memberUserIds := parseCSVValues(mustGetFlag(cmd, "users")) // 钉钉要求:群主(owner)必须是 useridlist 的成员之一。当前登录用户作为群主,须加入成员列表。 currentUserID, err := getCurrentUserID(ctx) if err != nil { return err } // 将当前用户置于首位(作为群主),避免重复添加 seen := map[string]bool{currentUserID: true} allMembers := []string{currentUserID} for _, uid := range memberUserIds { if !seen[uid] { seen[uid] = true allMembers = append(allMembers, uid) } } toolArgs := map[string]any{ "groupName": mustGetFlag(cmd, "name"), "groupMembers": allMembers, "groupType": groupType, } // 话题模式 thread, _ := cmd.Flags().GetBool("thread") if thread { toolArgs["convThreadEnabled"] = true } raw, err := callMCPToolReturnTextOnServer(ctx, "im", "create_group_conversation", toolArgs) if err != nil { return err } var resp map[string]any if json.Unmarshal([]byte(raw), &resp) == nil { if result, ok := resp["result"].(map[string]any); ok { if v, exists := result["openCid"]; exists { result["openConversationId"] = v delete(result, "openCid") } delete(result, "cid") } return deps.Out.PrintJSON(resp) } deps.Out.PrintRaw(raw) return nil }, } DeclareLeafMetadata(chatGroupCreateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_group_conversation", CanonicalPath: "chat.create_group_conversation", CLIPath: "chat group create", PrimaryCLIPath: "chat group create", }, Description: "创建群聊并可指定群名与初始成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_group_conversation"}, }, Selection: contract.SelectionSpec{ AgentSummary: "创建群聊并可指定群名与初始成员", UseWhen: []string{ "需要新建内部/外部群或话题圈,并带上初始成员", "用户明确要建群且已给出群名与成员标识", }, AvoidWhen: []string{ "已有群只需加人时使用 chat group members add", "只是搜索已有群时使用 chat search", }, Examples: []string{"dws chat group create --name \"Q1 项目冲刺群\" --users userId1,userId2,userId3"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "groupName"}, {Name: "thread", Property: "convThreadEnabled"}, {Name: "type", Property: "groupType"}, {Name: "users", Property: "groupMembers"}, }, }, }) chatSearchCmd := newChatGroupSearchCommand(false) chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true) cli.AttachRuntimeSchema( chatGroupSearchCompatibilityCmd, "chat", "search_groups", "reviewed-compatibility:chat-group-search", ) cli.AnnotateRuntimeCompatibilityEquivalence( chatSearchCmd, chatGroupSearchCompatibilityCmd, cli.RuntimeCompatibilityEquivalence{ ID: "chat-group-search-compatibility-v1", Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.", Reviewed: true, }, ) DeclareLeafMetadata(chatSearchCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_groups", CanonicalPath: "chat.search_groups", CLIPath: "chat search", PrimaryCLIPath: "chat search", Aliases: []string{"chat group search"}, }, Description: "按关键词搜索群聊并拿到 openConversationId", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.", }, Selection: contract.SelectionSpec{ AgentSummary: "按关键词搜索群聊并拿到 openConversationId", UseWhen: []string{"只知道群名关键词,需要定位群并提取 openConversationId"}, AvoidWhen: []string{ "已有群号时用 chat group get-by-group-id", "查我创建/管理的群用 chat group list-my-groups", "查与某人的共同群用 chat search-common", }, Examples: []string{ "dws chat search --query \"项目冲刺\"", "dws chat search --query \"项目冲刺\" --limit 20 --cursor 0", }, }, Parameters: []contract.ParamDecl{ {Name: "query", Property: "keyword"}, }, }, }) chatGroupMembersCmd := &cobra.Command{ Use: "members", Short: "群成员管理", Long: `查看群成员列表,分页查询指定群聊的成员。`, Example: ` dws chat group members --id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id"); err != nil { return err } toolArgs := map[string]any{ "openconversation_id": mustGetFlag(cmd, "id"), } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } return callMCPTool("get_group_members", toolArgs) }, } chatGroupMembersAddBotCmd := &cobra.Command{ Use: "add-bot", Short: "将机器人添加到群中", Long: `将自定义机器人添加到当前用户有管理权限的群聊中。如果没有权限则会报错。`, Example: ` dws chat group members add-bot --robot-code --id # 查询群 ID: dws chat search --query "群名" # robot-code: $DINGTALK_CHAT_ROBOT_CODE`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "robot-code", "id"); err != nil { return err } return callMCPToolOnServer("bot", "add_robot_to_group", map[string]any{ "robotCode": mustGetFlag(cmd, "robot-code"), "openConversationId": mustGetFlag(cmd, "id"), }) }, } DeclareLeafMetadata(chatGroupMembersAddBotCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_robot_to_group", CanonicalPath: "chat.add_robot_to_group", CLIPath: "chat group members add-bot", PrimaryCLIPath: "chat group members add-bot", }, Description: "把企业机器人拉进我有管理权限的群", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "add_robot_to_group"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把企业机器人拉进我有管理权限的群", UseWhen: []string{"需要把已有企业机器人加入指定群"}, AvoidWhen: []string{ "添加普通成员时使用 chat group members add", "移除群内机器人时使用 chat group members remove-bot", }, Examples: []string{"dws chat group members add-bot --robot-code --id "}, }, Parameters: []contract.ParamDecl{ {Name: "id", Property: "openConversationId"}, }, }, }) chatGroupRenameCmd := &cobra.Command{ Use: "rename", Short: "更新群名称", Example: ` dws chat group rename --id --name "新群名" # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id", "name"); err != nil { return err } return callMCPTool("update_group_name", map[string]any{ "openconversation_id": mustGetFlag(cmd, "id"), "group_name": mustGetFlag(cmd, "name"), }) }, } DeclareLeafMetadata(chatGroupRenameCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_group_name", CanonicalPath: "chat.update_group_name", CLIPath: "chat group rename", PrimaryCLIPath: "chat group rename", }, Description: "修改指定群聊的名称", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "update_group_name"}, }, Selection: contract.SelectionSpec{ AgentSummary: "修改指定群聊的名称", UseWhen: []string{"需要给已有群聊重命名时"}, AvoidWhen: []string{"只修改个人可见备注时不要使用群名称更新"}, Examples: []string{"dws chat group rename --id --name \"新群名\""}, }, Parameters: []contract.ParamDecl{ {Name: "id", Property: "openconversation_id"}, {Name: "name", Property: "group_name"}, }, }, }) chatGroupMemberAddCmd := &cobra.Command{ Use: "add", Short: "添加群成员", Long: `向指定群聊添加成员,需传入群 ID 与用户标识列表。支持 userId 和 openDingTalkId 混传。`, Example: ` dws chat group members add --id --users userId1,userId2 dws chat group members add --id --users openDingTalkId1,openDingTalkId2 dws chat group members add --id --users userId1,openDingTalkId1 # 查询群 ID: dws chat search --query "群名" # 查询 userId / openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id", "users"); err != nil { return err } toolArgs := map[string]any{ "openconversation_id": mustGetFlag(cmd, "id"), } appendChatIDArgs(toolArgs, parseCSVValues(mustGetFlag(cmd, "users")), "userId", "openDingtalkIds") return callMCPTool("add_group_member", toolArgs) }, } DeclareLeafMetadata(chatGroupMemberAddCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_group_member", CanonicalPath: "chat.add_group_member", CLIPath: "chat group members add", PrimaryCLIPath: "chat group members add", }, Description: "向指定群聊添加成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "add_group_member"}, }, Selection: contract.SelectionSpec{ AgentSummary: "向指定群聊添加成员", UseWhen: []string{"已知群 ID 和成员 ID,需要邀请成员入群时"}, AvoidWhen: []string{"添加机器人时使用 chat group members add-bot"}, Examples: []string{"dws chat group members add --id --users userId1,userId2"}, }, Parameters: []contract.ParamDecl{ {Name: "id", Property: "openconversation_id"}, {Name: "users", Property: "userId"}, }, }, }) chatGroupMemberRemoveCmd := &cobra.Command{ Use: "remove", Short: "移除群成员", Long: `从指定群聊中移除成员,需传入群 ID 与待移除的用户 ID 列表。`, Example: ` dws chat group members remove --id --users userId1,userId2 # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id", "users"); err != nil { return err } groupID := mustGetFlag(cmd, "id") removeValues := parseCSVValues(mustGetFlag(cmd, "users")) // 群主防护:移出群主会产生无群主的孤儿群,先在客户端拦截。 if err := guardGroupOwnerRemoval(cmd.Context(), groupID, removeValues); err != nil { return err } return callMCPTool("remove_group_member", map[string]any{ "openConversationId": groupID, "userIdList": removeValues, }) }, } DeclareLeafMetadata(chatGroupMemberRemoveCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_group_member", CanonicalPath: "chat.remove_group_member", CLIPath: "chat group members remove", PrimaryCLIPath: "chat group members remove", }, Description: "从指定群聊移除成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "remove_group_member"}, }, Selection: contract.SelectionSpec{ AgentSummary: "从指定群聊移除成员", UseWhen: []string{"群管理员明确要移除一个或多个普通成员时"}, AvoidWhen: []string{"移除机器人时使用 chat group members remove-bot"}, Examples: []string{"dws chat group members remove --id --users userId1,userId2"}, }, Parameters: []contract.ParamDecl{ {Name: "id", Property: "openConversationId"}, {Name: "users", Property: "userIdList"}, }, }, }) // ── message 子命令 ──────────────────────────────────────── chatMessageCmd := &cobra.Command{ Use: "message", Short: "会话消息管理", Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`, RunE: groupRunE, } chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--conversation-id 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, Example: ` dws chat message list --conversation-id --time "2025-03-01 00:00:00" dws chat message list --conversation-id dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 dws chat message list --conversation-id --time "2025-03-01 00:00:00" --direction older dws chat message list --conversation-id --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}' # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") specified := 0 if groupID != "" { specified++ } if userID != "" { specified++ } if openDingTalkID != "" { specified++ } if specified > 1 { return fmt.Errorf("--conversation-id, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") } if specified == 0 { return fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required") } if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { return err } } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" } timeVal := mustGetFlag(cmd, "time") defaultForward := true if timeVal == "" { timeVal = defaultChatMessageListTime() defaultForward = false } forward, err := resolveMessageForward(cmd, defaultForward) if err != nil { return err } if groupID != "" { toolArgs := map[string]any{ "openconversation_id": groupID, "time": timeVal, "forward": forward, } if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v } return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false) } toolArgs := map[string]any{ "time": timeVal, "forward": forward, } if userID != "" { toolArgs["userId"] = userID } else { toolArgs["openDingTalkId"] = openDingTalkID } if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v } return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false) }, } DeclareLeafMetadata(chatMessageListCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_conversation_message_v2", CanonicalPath: "chat.list_conversation_message_v2", CLIPath: "chat message list", PrimaryCLIPath: "chat message list", }, Description: "分页读取指定会话消息及其引用上下文", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "list_conversation_message_v2"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页读取指定会话消息及其引用上下文", UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"}, AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"}, Examples: []string{ "dws chat message list --group --limit 50", "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'", }, }, Parameters: []contract.ParamDecl{ {Name: "direction", Property: "forward"}, {Name: "group", Property: "openconversation_id", Required: boolPtr(false)}, }, }, }) chatMessageListDirectCmd := &cobra.Command{ Use: "list-direct", Short: "拉取单聊会话消息", Hidden: true, Long: `按对方 userId 或 openDingTalkId 拉取单聊会话消息。`, Example: ` dws chat message list-direct --user <对方userId> --time "2026-04-01 00:00:00" --forward true --limit 50 dws chat message list-direct --open-dingtalk-id --time "2026-04-01 00:00:00" --forward false --limit 20 # 查询 userId / openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") if userID != "" && openDingTalkID != "" { return fmt.Errorf("--user and --open-dingtalk-id are mutually exclusive") } if userID == "" && openDingTalkID == "" { return fmt.Errorf("--user or --open-dingtalk-id is required") } if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { return err } } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" } timeVal, _ := cmd.Flags().GetString("time") defaultForward := true if strings.TrimSpace(timeVal) == "" { timeVal = defaultChatMessageListTime() defaultForward = false } forward, err := resolveMessageForward(cmd, defaultForward) if err != nil { return err } toolArgs := map[string]any{ "time": timeVal, "forward": forward, } if userID != "" { toolArgs["userId"] = userID } else { toolArgs["openDingTalkId"] = openDingTalkID } if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { toolArgs["limit"] = v } return callMCPTool("list_individual_chat_message", toolArgs) }, } DeclareLeafMetadata(chatMessageListDirectCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_individual_chat_message", CanonicalPath: "chat.list_individual_chat_message", CLIPath: "chat message list-direct", PrimaryCLIPath: "chat message list-direct", }, Description: "读取与指定用户的单聊消息记录", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "list_individual_chat_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "读取与指定用户的单聊消息记录", UseWhen: []string{"用户明确要求查看和某人的一对一聊天时"}, AvoidWhen: []string{"跨群聊按发送者查询时使用 chat message list-by-sender"}, Examples: []string{"dws chat message list-direct --user --time \"2026-07-01 00:00:00\" --direction newer --limit 50"}, }, Parameters: []contract.ParamDecl{ {Name: "direction", Property: "forward"}, {Name: "open-dingtalk-id", Property: "openDingTalkId"}, {Name: "user", Property: "userId"}, }, }, }) chatMessageSendCmd := &cobra.Command{ Use: "send", Short: "以当前用户身份发送消息(--conversation-id 群聊 / --user 或 --open-dingtalk-id 单聊)", Long: `以当前用户身份发送消息。 ⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。 目标选择(三选一,必填): --conversation-id 群聊 openconversation_id --user 单聊接收人 userId --open-dingtalk-id 单聊接收人 openDingTalkId 纯文本 / Markdown 消息(默认): 无需指定 --msg-type,直接传消息内容即可。推荐使用 --content flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。 图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。 如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。 返回值与后续操作: 发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用 dws chat message query-send-status --open-task-id 获取 openMessageId 和 openConversationId,无需再按消息内容反查。 本地图片 / 文件消息: 统一使用 --msg-type file --file <本地路径>。CLI 会完成上传并按 file 消息发送; .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。 旧版内联图片消息: 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。 当前 CLI 不提供本地文件到 mediaId 的上传能力。`, Example: ` dws chat message send --conversation-id --content "hello" dws chat message send --user --content "请查收" dws chat message send --open-dingtalk-id --content "请查收" dws chat message send --conversation-id --title "周报提醒" --content "请大家本周五前提交周报" # 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示 dws chat message send --conversation-id --content $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)' # 发送本地图片或文件(图片会作为可下载的 file 附件发送) dws chat message send --conversation-id --msg-type file --file ./screenshot.png dws chat message send --conversation-id --msg-type file --file ./report.pdf # 发送本地音频/视频(audio/video 是 file 的语义别名) dws chat message send --conversation-id --msg-type audio --file ./recording.mp3 dws chat message send --conversation-id --msg-type video --file ./demo.mp4 # 旧版内联图片:仅当上游已经持有有效 mediaId 时使用 dws chat message send --conversation-id --msg-type image --media-id # 查询群 ID: dws chat search --query "群名" # 查询用户 ID: dws contact user search --query "姓名"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid") specified := 0 if groupID != "" { specified++ } if userID != "" { specified++ } if openDingTalkID != "" { specified++ } if specified > 1 { return fmt.Errorf("--conversation-id, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") } if specified == 0 { return fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required") } if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { return err } } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" } // 数字 userId 尝试 lookup 转换为 openDingTalkId,让所有消息类型(文本/媒体)都走 openDingTalkId 路径。 // 真实后端的 send_personal_message 单聊路径稳定接受 receiverOpenDingTalkId; // userId/uid 直传会被服务端判定为空,因此解析失败时直接返回明确错误。 if userID != "" { resolved, err := resolveOpenDingTalkID(cmd.Context(), userID) if err != nil { return fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err) } else { if commandBoolFlag(cmd, "debug") || commandBoolFlag(cmd, "verbose") { fmt.Fprintf(os.Stderr, "[debug] resolved userID=%q to openDingTalkId=%q\n", userID, resolved) } openDingTalkID = resolved userID = "" } } if commandBoolFlag(cmd, "debug") || commandBoolFlag(cmd, "verbose") { fmt.Fprintf(os.Stderr, "[debug] message send after normalization: groupID=%q userID=%q openDingTalkID=%q\n", groupID, userID, openDingTalkID) } mediaId, _ := cmd.Flags().GetString("media-id") msgType, _ := cmd.Flags().GetString("msg-type") clawType := "" aiTag, _ := cmd.Flags().GetBool("ai-tag") if aiTag { clawType = edition.ClawType() } // ── 富媒体消息(image/audio/video/file) ── // text/markdown 透传到下方的文本消息分支,避免模型填 --msg-type text 报 unsupported if msgType == "text" || msgType == "markdown" { msgType = "" } if msgType != "" { var contentJSON string serviceMsgType := msgType switch msgType { case "image": if mediaId == "" { return fmt.Errorf("--media-id is required for msgType=image") } contentJSON = fmt.Sprintf(`{"mediaId":"%s"}`, mediaId) case "file", "audio", "video": serviceMsgType = "file" filePath := flagOrFallback(cmd, "file-path", "file") dentryId, _ := cmd.Flags().GetInt64("dentry-id") spaceId, _ := cmd.Flags().GetInt64("space-id") if (dentryId == 0) != (spaceId == 0) { return fmt.Errorf("--dentry-id and --space-id must be specified together") } if filePath != "" { meta, err := buildConversationLocalFileMeta(filePath, "", "") if err == nil && dentryId != 0 && spaceId != 0 { contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta) } else if err == nil { targetArgs, _ := buildConversationTargetArgs(cmd) if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "上传本地文件并发送 file 消息") deps.Out.PrintKeyValue("文件", meta.LocalPath) deps.Out.PrintKeyValue("名称", meta.FileName) deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", meta.FileSize)) return nil } ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() commitText, err := uploadConversationLocalFile(ctx, targetArgs, meta, "") if err != nil { return err } dentryId, spaceId, err = parseConversationFileSendIDs(commitText) if err != nil { return err } contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta) } else if dentryId == 0 || spaceId == 0 { return fmt.Errorf("--file must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err) } } if contentJSON == "" { fileName, _ := cmd.Flags().GetString("file-name") fileType, _ := cmd.Flags().GetString("file-type") fileSize, _ := cmd.Flags().GetInt64("file-size") if dentryId == 0 || spaceId == 0 || fileName == "" { return fmt.Errorf("readable local --file is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported") } contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`, dentryId, spaceId, fileName, fileType, filePath, fileSize) } case "location": latitude, _ := cmd.Flags().GetString("latitude") longitude, _ := cmd.Flags().GetString("longitude") locationName, _ := cmd.Flags().GetString("location-name") mapThumbnailUrl, _ := cmd.Flags().GetString("map-thumbnail-url") if latitude == "" || longitude == "" || locationName == "" || mapThumbnailUrl == "" { return fmt.Errorf("--latitude, --longitude, --location-name, --map-thumbnail-url are all required for msgType=location") } contentJSON = fmt.Sprintf(`{"locationName":"%s","longitude":"%s","latitude":"%s","mapThumbnailUrl":"%s"}`, locationName, longitude, latitude, mapThumbnailUrl) case "profile": contactID, _ := cmd.Flags().GetString("contact-id") if contactID == "" { return fmt.Errorf("--contact-id is required for msgType=profile") } contentJSON = fmt.Sprintf(`{"openDingTalkId":"%s"}`, contactID) default: return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video, location, profile)", msgType) } params := map[string]any{ "msgType": serviceMsgType, "content": contentJSON, "clawType": clawType, } if groupID != "" { params["openConversationId"] = groupID } else { params["receiverOpenDingTalkId"] = openDingTalkID } if msgUuid != "" { params["uuid"] = msgUuid } return callMCPTool("send_personal_message", params) } // ── 文本/Markdown 消息 ── text := flagOrFallback(cmd, "text", "content", "body", "message", "markdown") if text == "" && len(args) > 0 { text = args[0] } if text == "" { return fmt.Errorf("message content required (use --content or positional arg, or --media-id for image)") } title, _ := cmd.Flags().GetString("title") if title == "" { title = sanitizeTitleFromText(text) } if groupID != "" { atAll, _ := cmd.Flags().GetBool("at-all") atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids") // 群聊统一走 openDingTalkId @ 人接口。 newParams := map[string]any{ "openConversationId": groupID, "msgType": "markdown", "clawType": clawType, } text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll) contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text}) newParams["content"] = string(contentJSON) if msgUuid != "" { newParams["uuid"] = msgUuid } return callMCPTool("send_personal_message", newParams) } // 单聊:统一走 openDingTalkId directContentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text}) newDirectParams := map[string]any{ "receiverOpenDingTalkId": openDingTalkID, "msgType": "markdown", "content": string(directContentJSON), "clawType": clawType, } if msgUuid != "" { newDirectParams["uuid"] = msgUuid } return callMCPTool("send_personal_message", newDirectParams) }, } DeclareLeafMetadata(chatMessageSendCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "send_personal_message", CanonicalPath: "chat.send_personal_message", CLIPath: "chat message send", PrimaryCLIPath: "chat message send", }, Description: "以当前用户身份发送群聊或单聊消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "以当前用户身份发送群聊或单聊消息", UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"}, AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"}, Examples: []string{"dws chat message send --group --content \"项目已更新\""}, }, Parameters: []contract.ParamDecl{ {Name: "ai-tag", Property: "clawType", InterfaceType: "string"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "idempotency-key", Property: "uuid"}, {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"}, }, }, }) // send-by-bot: 群聊传 --conversation-id,单聊传 --users/--open-dingtalk-ids。 // Markdown 使用 --title/--text,图片使用 --msg-type image/--image-url, // 文件使用 --msg-type file/--file-path,CLI 上传后发送。 chatMessageSendByBotCmd := &cobra.Command{ Use: "send-by-bot", Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)", Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。机器人引用回复仅支持群聊 Markdown,同时传 --reply(原消息 openMessageId)和 --ref-sender(原消息发送者 openDingTalkId);回复可省略 --title,CLI 会从正文生成标题。 ⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`, Example: ` dws chat message send-by-bot --robot-code --conversation-id --title "日报" --text "## 今日完成..." dws chat message send-by-bot --robot-code --conversation-id --reply --ref-sender --text "收到" dws chat message send-by-bot --robot-code --conversation-id --msg-type image --image-url "https://example.com/image.png" dws chat message send-by-bot --robot-code --conversation-id --msg-type file --file-path ./report.pdf dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" dws chat message send-by-bot --robot-code --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报" dws chat message send-by-bot --robot-code --conversation-id --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告" dws chat message send-by-bot --robot-code --conversation-id --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" dws chat message send-by-bot --robot-code --conversation-id --at-all --title "通知" --text "请所有人注意" # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名" # robot-code: $DINGTALK_CHAT_ROBOT_CODE`, RunE: func(cmd *cobra.Command, args []string) error { msgType := strings.TrimSpace(mustGetFlag(cmd, "msg-type")) title := mustGetFlag(cmd, "title") text := mustGetFlag(cmd, "text") imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url")) filePath := mustGetFlag(cmd, "file-path") referenceOpenMessageID := strings.TrimSpace(mustGetFlag(cmd, "reply")) refSender := strings.TrimSpace(mustGetFlag(cmd, "ref-sender")) replyChanged := cmd.Flags().Changed("reply") refSenderChanged := cmd.Flags().Changed("ref-sender") if err := validateRequiredFlags(cmd, "robot-code"); err != nil { return err } if replyChanged != refSenderChanged || (replyChanged && (referenceOpenMessageID == "" || refSender == "")) { return fmt.Errorf("--reply and --ref-sender must be specified together") } if msgType == "" { if imageURL != "" { return fmt.Errorf("--msg-type image is required when using --image-url") } if filePath != "" { return fmt.Errorf("--msg-type file is required when using --file-path") } msgType = "markdown" } switch msgType { case "markdown": if referenceOpenMessageID != "" { if err := validateRequiredFlags(cmd, "text"); err != nil { return err } if title == "" { title = sanitizeTitleFromText(text) } } else { if err := validateRequiredFlags(cmd, "title", "text"); err != nil { return err } } case "image": if imageURL == "" { return fmt.Errorf("--image-url is required for --msg-type image") } case "file": if filePath == "" { return fmt.Errorf("--file-path is required for --msg-type file") } default: return fmt.Errorf("unsupported --msg-type %q, must be one of: markdown, image, file", msgType) } isMarkdownMessage := msgType == "markdown" chatID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") usersStr, _ := cmd.Flags().GetString("users") openDingtalkIdsStr, _ := cmd.Flags().GetString("open-dingtalk-ids") hasDirectTarget := usersStr != "" || openDingtalkIdsStr != "" if chatID != "" && hasDirectTarget { return fmt.Errorf("--conversation-id and --users/--open-dingtalk-ids are mutually exclusive") } if chatID == "" && !hasDirectTarget { return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required") } if referenceOpenMessageID != "" { if chatID == "" { return fmt.Errorf("--reply and --ref-sender are only supported with --conversation-id") } if msgType != "markdown" { return fmt.Errorf("--reply and --ref-sender only support Markdown group messages") } if !isOpenDingTalkID(refSender) { resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender) if err != nil { return err } refSender = resolved } } userIDs := splitCommaList(usersStr) openDingTalkIDs := splitCommaList(openDingtalkIdsStr) fileURL := "" if msgType == "file" { var err error fileURL, err = uploadRobotMessageLocalFile( cmd, filePath, chatID, userIDs, openDingTalkIDs) if err != nil { return err } if deps.Caller.DryRun() { return nil } } buildRobotMessageArgs := func(markdown string) map[string]any { toolArgs := map[string]any{ "robotCode": mustGetFlag(cmd, "robot-code"), } switch msgType { case "image": toolArgs["photoURL"] = imageURL case "file": toolArgs["fileUrl"] = fileURL default: toolArgs["title"] = title toolArgs["markdown"] = markdown } return toolArgs } if chatID != "" { var atUserIds []string if atUserIdsStr, _ := cmd.Flags().GetString("at-user-ids"); atUserIdsStr != "" { for _, id := range strings.Split(atUserIdsStr, ",") { if s := strings.TrimSpace(id); s != "" { atUserIds = append(atUserIds, s) } } } var atOpenDingtalkIds []string if atOpenDingtalkIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids"); atOpenDingtalkIdsStr != "" { for _, id := range strings.Split(atOpenDingtalkIdsStr, ",") { if s := strings.TrimSpace(id); s != "" { atOpenDingtalkIds = append(atOpenDingtalkIds, s) } } } markdown := text if isMarkdownMessage { // 机器人发消息要求 @ 占位符为裸 @id;模型若写成 <@id> 会导致 @ 不生效,主动剥离尖括号 markdown = normalizeAtPlaceholders(markdown, atUserIds, false) markdown = normalizeAtPlaceholders(markdown, atOpenDingtalkIds, false) markdown = strings.ReplaceAll(markdown, "<@all>", "@all") } toolArgs := buildRobotMessageArgs(markdown) switch msgType { case "image": toolArgs["msgKey"] = "sampleImageMsg" case "file": toolArgs["msgKey"] = "sampleDingtalkDriveFile" default: toolArgs["msgKey"] = "sampleMarkdownDX" } toolArgs["openConversationId"] = chatID if referenceOpenMessageID != "" { toolArgs["referenceOpenMessageId"] = referenceOpenMessageID toolArgs["srcMsgSendOpenDingTalkId"] = refSender } if len(atUserIds) > 0 { toolArgs["atUserIds"] = atUserIds } if len(atOpenDingtalkIds) > 0 { toolArgs["atOpendingtalkIds"] = atOpenDingtalkIds } if isAtAll, _ := cmd.Flags().GetBool("at-all"); isAtAll { toolArgs["isAtAll"] = "true" } return callMCPToolOnServer("bot", "send_robot_group_message", toolArgs) } toolArgs := buildRobotMessageArgs(text) switch msgType { case "image": toolArgs["msgType"] = "sampleImageMsg" case "file": toolArgs["msgType"] = "sampleDingtalkDriveFile" default: toolArgs["msgType"] = "sampleMarkdownDX" } if len(userIDs) > 0 { toolArgs["userIds"] = userIDs } if len(openDingTalkIDs) > 0 { toolArgs["openDingtalkIds"] = openDingTalkIDs } if isAtAll, _ := cmd.Flags().GetBool("at-all"); isAtAll { toolArgs["isAtAll"] = "true" } return callMCPToolOnServer("bot", "batch_send_robot_msg_to_users", toolArgs) }, } DeclareLeafMetadata(chatMessageSendByBotCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "send_robot_message", CanonicalPath: "chat.send_robot_message", CLIPath: "chat message send-by-bot", PrimaryCLIPath: "chat message send-by-bot", }, Description: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref", }, Selection: contract.SelectionSpec{ AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊", UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时", "需要机器人在群聊中引用回复一条已有消息时"}, AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"}, Examples: []string{ "dws chat message send-by-bot --robot-code --group --title \"日报\" --text \"今日进展\"", "dws chat message send-by-bot --robot-code --group --reply --ref-sender --text \"收到\"", }, }, // Keep title/text required_when out of Schema: the runtime switch above // enforces Markdown inputs, while adding it breaks merge-base compatibility. Parameters: []contract.ParamDecl{ {Name: "group", Property: "group", Required: boolPtr(false)}, {Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}}, {Name: "image-url", RequiredWhen: "msg-type is image"}, {Name: "file-path", RequiredWhen: "msg-type is file"}, {Name: "reply", Property: "referenceOpenMessageId", RequiredWhen: "ref-sender is provided", InterfaceType: "string"}, {Name: "ref-sender", Property: "srcMsgSendOpenDingTalkId", RequiredWhen: "reply is provided", InterfaceType: "string"}, }, }, }) // recall-by-bot: 传 --conversation-id 为群聊撤回,不传为单聊撤回 chatMessageRecallByBotCmd := &cobra.Command{ Use: "recall-by-bot", Short: "机器人撤回消息(--conversation-id 群聊 / 不传为单聊)", Long: `群聊:传 --conversation-id 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。`, Example: ` dws chat message recall-by-bot --robot-code --conversation-id --keys dws chat message recall-by-bot --robot-code --keys key1,key2 # 查询群 ID: dws chat search --query "群名" # robot-code: $DINGTALK_CHAT_ROBOT_CODE`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "robot-code", "keys"); err != nil { return err } keysStr := mustGetFlag(cmd, "keys") var processQueryKeys []string for _, k := range strings.Split(keysStr, ",") { if s := strings.TrimSpace(k); s != "" { processQueryKeys = append(processQueryKeys, s) } } chatID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if chatID != "" { return callMCPToolOnServer("bot", "recall_robot_group_message", map[string]any{ "robotCode": mustGetFlag(cmd, "robot-code"), "openConversationId": chatID, "processQueryKeys": processQueryKeys, }) } return callMCPToolOnServer("bot", "batch_recall_robot_users_msg", map[string]any{ "robotCode": mustGetFlag(cmd, "robot-code"), "processQueryKeys": processQueryKeys, }) }, } DeclareLeafMetadata(chatMessageRecallByBotCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "recall_robot_message", CanonicalPath: "chat.recall_robot_message", CLIPath: "chat message recall-by-bot", PrimaryCLIPath: "chat message recall-by-bot", }, Description: "撤回指定机器人发送的消息", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref", }, Selection: contract.SelectionSpec{ AgentSummary: "撤回指定机器人发送的消息", UseWhen: []string{"持有 robotCode 和发送结果 key,需要撤回机器人消息时"}, AvoidWhen: []string{"个人身份消息撤回使用 chat message recall"}, Examples: []string{"dws chat message recall-by-bot --robot-code --conversation-id --keys "}, }, }, }) chatMessageSendByWebhookCmd := &cobra.Command{ Use: "send-by-webhook", Short: "自定义机器人 Webhook 发送群消息", Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --content 中包含 @userId 或 @手机号,否则 @ 不生效。 ⚠️ 重要:该接口会真实发送消息到目标群聊,不可用于测试或试探性调用。调用前必须确认消息内容无误。`, Example: ` dws chat message send-by-webhook --token --title "告警" --content "CPU 超 90%" --at-all dws chat message send-by-webhook --token --title "test" --content "hi @118785" --at-users 118785`, PreRunE: func(cmd *cobra.Command, args []string) error { return promoteLegacyChatString(cmd, "content", "text") }, RunE: func(cmd *cobra.Command, args []string) error { if err := promoteLegacyChatString(cmd, "content", "text"); err != nil { return err } if err := validateRequiredFlags(cmd, "token", "title", "content"); err != nil { return err } toolArgs := map[string]any{ "robotToken": mustGetFlag(cmd, "token"), "title": mustGetFlag(cmd, "title"), "text": mustGetFlag(cmd, "content"), } if v, _ := cmd.Flags().GetBool("at-all"); v { toolArgs["isAtAll"] = true } if v, _ := cmd.Flags().GetString("at-mobiles"); v != "" { var mobiles []string for _, m := range strings.Split(v, ",") { if s := strings.TrimSpace(m); s != "" { mobiles = append(mobiles, s) } } toolArgs["atMobiles"] = mobiles } if v, _ := cmd.Flags().GetString("at-users"); v != "" { var atUserIds []string for _, u := range strings.Split(v, ",") { if s := strings.TrimSpace(u); s != "" { atUserIds = append(atUserIds, s) } } toolArgs["atUserIds"] = atUserIds } // dry-run 只预览参数,不实际发送,交回标准调用路径。 if deps.Caller.DryRun() { return callMCPToolOnServer("bot", "send_message_by_custom_robot", toolArgs) } // 自定义机器人 webhook 即使发送失败(如 errcode=300005 token 不存在) // HTTP 仍是 200,会被包成 success:true。这里取原始响应显式识别 errcode, // 非 0 时按失败返回,避免 agent 误判消息已发出。 raw, err := callMCPToolReturnTextOnServer(context.Background(), "bot", "send_message_by_custom_robot", toolArgs) if err != nil { return err } if code, msg, failed := webhookErrcodeFailure(raw); failed { return &CLIError{ Code: CodeMCPToolError, Message: fmt.Sprintf("自定义机器人 webhook 发送失败: errcode=%s errmsg=%s", code, msg), Suggestion: "检查 --token 是否有效、机器人是否仍在群内、以及机器人安全设置(关键词/IP/加签)是否拦截", } } var parsed any if json.Unmarshal([]byte(raw), &parsed) == nil { return deps.Out.PrintJSON(parsed) } deps.Out.PrintRaw(raw) return nil }, } DeclareLeafMetadata(chatMessageSendByWebhookCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "send_message_by_custom_robot", CanonicalPath: "chat.send_message_by_custom_robot", CLIPath: "chat message send-by-webhook", PrimaryCLIPath: "chat message send-by-webhook", }, Description: "用自定义机器人 Webhook 向群发送消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "send_message_by_custom_robot"}, }, Selection: contract.SelectionSpec{ AgentSummary: "用自定义机器人 Webhook 向群发送消息", UseWhen: []string{"已有自定义机器人 webhook token,需要向群发告警或通知"}, AvoidWhen: []string{ "使用企业机器人 robot-code 发消息时用 chat message send-by-bot", "以个人身份发消息时用 chat message send", }, Examples: []string{"dws chat message send-by-webhook --token --title \"告警\" --content \"CPU 超 90%\" --at-all"}, }, Parameters: []contract.ParamDecl{ {Name: "at-all", Property: "isAtAll"}, {Name: "at-users", Property: "atUserIds"}, {Name: "content", Property: "text"}, {Name: "token", Property: "robotToken"}, }, }, }) chatMessageListTopicRepliesCmd := &cobra.Command{ Use: "list-topic-replies", Short: "拉取群话题回复消息列表", Long: `查询指定群聊中某条话题消息的全部回复。--conversation-id 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。`, Example: ` dws chat message list-topic-replies --conversation-id --topic-id dws chat message list-topic-replies --conversation-id --topic-id --time "2025-03-01 00:00:00" --limit 20 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } if err := validateRequiredFlags(cmd, "topic-id"); err != nil { return err } toolArgs := map[string]any{ "openconversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "topicId": mustGetFlag(cmd, "topic-id"), } if v, _ := cmd.Flags().GetString("time"); v != "" { toolArgs["startTime"] = v } if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { toolArgs["pageSize"] = v } forward, err := resolveMessageForward(cmd, false) if err != nil { return err } toolArgs["forward"] = forward return callMCPTool("list_topic_replies", toolArgs) }, } DeclareLeafMetadata(chatMessageListTopicRepliesCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_topic_replies", CanonicalPath: "chat.list_topic_replies", CLIPath: "chat message list-topic-replies", PrimaryCLIPath: "chat message list-topic-replies", }, Description: "分页读取指定话题的回复", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "list_topic_replies"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页读取指定话题的回复", UseWhen: []string{"已知话题 ID 并需要查看回复串时"}, AvoidWhen: []string{"读取普通会话消息时使用 chat message list"}, Examples: []string{"dws chat message list-topic-replies --conversation-id --topic-id --limit 50"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openconversationId"}, {Name: "direction", Property: "forward"}, {Name: "limit", Property: "pageSize"}, {Name: "time", Property: "startTime"}, }, }, }) chatMessageListAllCmd := &cobra.Command{ Use: "list-all", Short: "拉取指定时间范围内当前用户的所有会话消息", Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_messages_by_time_range", chatMessageListAllArgs)) }, } DeclareLeafMetadata(chatMessageListAllCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_messages_by_time_range", CanonicalPath: "chat.search_messages_by_time_range", CLIPath: "chat message list-all", PrimaryCLIPath: "chat message list-all", }, Description: "按时间范围搜索跨会话消息并保留权益指引", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "search_messages_by_time_range"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按时间范围搜索跨会话消息并保留权益指引", UseWhen: []string{"需要汇总一段时间内所有可见会话消息时"}, AvoidWhen: []string{"已指定单个会话时优先使用 chat message list"}, Examples: []string{ "dws chat message list-all --start \"2026-07-01 00:00:00\" --end \"2026-07-02 00:00:00\" --limit 50", "dws chat message list-all --start \"2026-07-01 00:00:00\" --end \"2026-07-02 00:00:00\" --limit 100 --page-all --page-limit 20", }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, }) chatMessageListBySenderCmd := &cobra.Command{ Use: "list-by-sender", Short: "拉取指定发送者的消息(包含单聊和群聊)", Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --page-limit 10 --page-delay 0 # 查询 userId: dws contact user search --query "姓名" # 查询 openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_messages_by_sender", chatMessageListBySenderArgs)) }, } DeclareLeafMetadata(chatMessageListBySenderCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_messages_by_sender", CanonicalPath: "chat.search_messages_by_sender", CLIPath: "chat message list-by-sender", PrimaryCLIPath: "chat message list-by-sender", }, Description: "按发送者和时间范围查询消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "search_messages_by_sender"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按发送者和时间范围查询消息", UseWhen: []string{"需要查某人发送过的消息且不限定单聊时"}, AvoidWhen: []string{"明确查询与某人的单聊记录时使用 chat message list-direct"}, Examples: []string{ "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50", "dws chat message list-by-sender --sender-user-id --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50 --page-all --page-limit 10", }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "sender-open-dingtalk-id", Property: "senderOpenDingTalkId"}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, }) chatMessageListMentionsCmd := &cobra.Command{ Use: "list-mentions", Short: "拉取 @我 的消息", Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 dws chat message list-mentions --conversation-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor dws chat message list-mentions --conversation-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --page-all --max-items 200 --page-delay 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( "search_at_me_message", chatMessageListMentionsArgs)) }, } DeclareLeafMetadata(chatMessageListMentionsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_at_me_message", CanonicalPath: "chat.search_at_me_message", CLIPath: "chat message list-mentions", PrimaryCLIPath: "chat message list-mentions", }, Description: "查询指定时间范围内提及当前用户的消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "search_at_me_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定时间范围内提及当前用户的消息", UseWhen: []string{"需要找出 @我的消息和待关注事项时"}, AvoidWhen: []string{"查询全部消息时使用 chat message list-all"}, Examples: []string{ "dws chat message list-mentions --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50", "dws chat message list-mentions --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-02T00:00:00+08:00\" --limit 50 --page-all --max-items 200", }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, }) chatMessageListFocusedCmd := &cobra.Command{ Use: "list-focused", Short: "拉取特别关注人的消息", Long: `拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传数字分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用数字 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-focused --limit 50 dws chat message list-focused --limit 20 --cursor dws chat message list-focused --limit 50 --page-all --page-limit 10 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { return RunPagedMCPCommand(cmd, pagedChatMessagesInt64Config( "list_special_focus_messages", chatMessageListFocusedArgs)) }, } DeclareLeafMetadata(chatMessageListFocusedCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_special_focus_messages", CanonicalPath: "chat.list_special_focus_messages", CLIPath: "chat message list-focused", PrimaryCLIPath: "chat message list-focused", }, Description: "列出当前用户特别关注的消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "list_special_focus_messages"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出当前用户特别关注的消息", UseWhen: []string{"需要查看特别关注或重点消息列表时"}, AvoidWhen: []string{"普通未读消息或提及消息使用对应专用命令"}, Examples: []string{ "dws chat message list-focused --limit 50", "dws chat message list-focused --limit 50 --page-all --page-limit 10", }, }, Parameters: pagedMCPParamDecls(), }, }) chatMessageListTopConversationsCmd := &cobra.Command{ Use: "list-top-conversations", Short: "拉取置顶会话列表", Long: `拉取当前用户的置顶会话列表。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`, Example: ` dws chat list-top-conversations --limit 1000 dws chat list-top-conversations --limit 1000 --cursor `, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 { toolArgs["cursor"] = v } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPTool("list_top_conversations", toolArgs) }, } DeclareLeafMetadata(chatMessageListTopConversationsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_top_conversations", CanonicalPath: "chat.list_top_conversations", CLIPath: "chat list-top-conversations", PrimaryCLIPath: "chat list-top-conversations", }, Description: "列出当前用户置顶的会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "list_top_conversations"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出当前用户置顶的会话", UseWhen: []string{"需要查看现有置顶会话清单时"}, AvoidWhen: []string{"设置或取消某个会话置顶时使用 chat set-top"}, Examples: []string{"dws chat list-top-conversations --limit 100"}, }, }, }) chatMessageListUnreadConversationsCmd := &cobra.Command{ Use: "list-unread-conversations", Short: "获取未读会话列表", Long: `获取当前用户有未读消息的会话列表,可选通过 --count 控制返回的会话条数。`, Example: ` dws chat message list-unread-conversations dws chat message list-unread-conversations --count 20`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if count, err := cmd.Flags().GetInt("count"); err == nil && count > 0 { toolArgs["count"] = count } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPTool("unread_message_conversation_list", toolArgs) }, } DeclareLeafMetadata(chatMessageListUnreadConversationsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "unread_message_conversation_list", CanonicalPath: "chat.unread_message_conversation_list", CLIPath: "chat message list-unread-conversations", PrimaryCLIPath: "chat message list-unread-conversations", }, Description: "列出当前用户存在未读消息的会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "unread_message_conversation_list"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出当前用户存在未读消息的会话", UseWhen: []string{"需要定位哪些会话还有未读内容时"}, AvoidWhen: []string{"查询某条消息的已读人员时使用 chat message read-status"}, Examples: []string{"dws chat message list-unread-conversations --count 50"}, }, }, }) chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --conversation-id 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --conversation-id --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}' # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") return runConversationScopedPagedMessageSearch( cmd, pagedProjectedChatSearchConfig(cmd, "search_messages_by_keyword", chatMessageSearchArgs), "openConversationId", []string{groupID}, ) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_messages_by_keyword", CanonicalPath: "chat.search_messages_by_keyword", CLIPath: "chat message search", PrimaryCLIPath: "chat message search", }, Description: "按关键词和时间范围搜索消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "search_messages_by_keyword"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按关键词和时间范围搜索消息", UseWhen: []string{"需要用关键词查找消息且过滤条件较简单时"}, AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"}, Examples: []string{ "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"", "dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300 --jq '.messages[] | {messageId, text}'", }, }, Parameters: append([]contract.ParamDecl{ {Name: "end", Property: "endTime"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "query", Property: "keyword"}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, }) // ── search-advanced:多维度搜索消息 ────── chatMessageSearchAdvancedCmd := &cobra.Command{ Use: "search-advanced", Short: "多维度搜索消息", Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。所有参数均为可选,至少指定一个搜索条件。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --sender-ids , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0 dws chat message search-advanced --conversation-ids <单聊openConversationId> --query "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --page-limit 20 --max-items 500 # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { conversationIDs := parseCSVValues(flagOrFallback(cmd, "conversation-ids", "groups", "group")) return runConversationScopedPagedMessageSearch( cmd, pagedChatConversationMessagesOnServerConfig("im", "search_messages", chatMessageSearchAdvancedArgs), "openConversationIds", conversationIDs, ) }, } DeclareLeafMetadata(chatMessageSearchAdvancedCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_messages", CanonicalPath: "chat.search_messages", CLIPath: "chat message search-advanced", PrimaryCLIPath: "chat message search-advanced", }, Description: "按时间、关键词、发送者、@ 或会话等多维度搜索消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "search_messages"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按时间、关键词、发送者、@ 或会话等多维度搜索消息", UseWhen: []string{"需要组合时间范围、关键词、发送者、@我/@某人、指定会话等条件搜消息"}, AvoidWhen: []string{ "只需按关键词在会话里搜时优先评估 chat message search", "只需拉取某会话时间线时使用 chat message list", "只需某人发给我的消息时使用 chat message list-by-sender", }, Examples: []string{ "dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\"", "dws chat message search-advanced --query \"周报\" --start \"2026-04-01T00:00:00+08:00\" --end \"2026-04-15T00:00:00+08:00\" --page-all --page-limit 20", }, }, Parameters: append([]contract.ParamDecl{ {Name: "at-ids", Property: "atOpenDingTakIds"}, {Name: "conversation-ids", Property: "openConversationIds"}, {Name: "conversation-type", Property: "searchConvType"}, {Name: "end", Property: "endTime"}, {Name: "message-type", Property: "messageType"}, {Name: "only-robot", Property: "onlyRobotMessages"}, {Name: "query", Property: "keyword"}, {Name: "sender-ids", Property: "senderOpenDingTakIds"}, {Name: "start", Property: "startTime"}, }, pagedMCPParamDecls()...), }, }) // ── query-send-status:查询消息发送状态(走 IM MCP)────── chatMessageQuerySendStatusCmd := &cobra.Command{ Use: "query-send-status", Aliases: []string{"send-status"}, Short: "查询消息发送状态", Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。 发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为 chat message edit 或 chat message recall 的 --message-id 和 --conversation-id。 同一组 ID 也可继续用于 chat message read-status(消息参数为 --message-id);openTaskId 本身不是消息 ID。`, Example: ` dws chat message query-send-status --open-task-id dws chat message recall --conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-task-id"); err != nil { return err } return callMCPToolOnServer("im", "query_message_send_status", map[string]any{ "openTaskId": mustGetFlag(cmd, "open-task-id"), }) }, } DeclareLeafMetadata(chatMessageQuerySendStatusCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "query_message_send_status", CanonicalPath: "chat.query_message_send_status", CLIPath: "chat message query-send-status", PrimaryCLIPath: "chat message query-send-status", Aliases: []string{"chat message send-status"}, }, Description: "查询异步消息发送任务的状态", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "query_message_send_status"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查询异步消息发送任务的状态", UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果,或后续 edit/recall/read-status 需要先取得 openMessageId 和 openConversationId 时"}, AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"}, Examples: []string{"dws chat message query-send-status --open-task-id "}, }, }, }) // ── recall:撤回用户发送的消息(走 IM MCP)────── chatMessageRecallCmd := &cobra.Command{ Use: "recall", Short: "撤回用户发送的消息", Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`, Example: ` dws chat message recall --conversation-id --message-id # 发送后撤回:send -> query-send-status -> recall dws chat message send --conversation-id --content "待撤回的内容" dws chat message query-send-status --open-task-id <上一步返回的openTaskId> dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } return callMCPToolOnServer("im", "recall_message", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } DeclareLeafMetadata(chatMessageRecallCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "recall_message", CanonicalPath: "chat.recall_message", CLIPath: "chat message recall", PrimaryCLIPath: "chat message recall", }, Description: "撤回当前用户已发送的单条消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "recall_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "撤回当前用户已发送的单条消息", UseWhen: []string{"已知会话与消息 ID,需要撤回自己发出的那条消息"}, AvoidWhen: []string{ "撤回机器人消息时使用 chat message recall-by-bot", "消息 ID 未确认时先用消息查询/搜索拿到 openMessageId", }, Examples: []string{"dws chat message recall --conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, }, }, }) chatMessageEditCmd := &cobra.Command{ Use: "edit", Short: "编辑消息", Long: `编辑指定消息的内容。需要指定会话 ID 和消息 ID。 推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。 也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`, Example: ` dws chat message edit --conversation-id --message-id --text "更新后的内容" # 发送后编辑:send -> query-send-status -> edit dws chat message send --conversation-id --content "原始内容" dws chat message query-send-status --open-task-id <上一步返回的openTaskId> dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容" dws chat message edit --conversation-id --message-id --title "标题" --text "更新后的内容" dws chat message edit --conversation-id --message-id --text "<@all> 请查看" --at-all dws chat message edit --conversation-id --message-id --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids dws chat message edit --conversation-id --message-id --content '{"title":"标题","text":"更新后的内容"}'`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } content, _ := cmd.Flags().GetString("content") text, _ := cmd.Flags().GetString("text") if content == "" && text == "" { return fmt.Errorf("flag --text or --content is required") } if content != "" && text != "" { return fmt.Errorf("--text and --content are mutually exclusive") } atAll, _ := cmd.Flags().GetBool("at-all") atOpenIDs := parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")) if text != "" { title := mustGetFlag(cmd, "title") if title == "" { title = sanitizeTitleFromText(text) } if atAll && !strings.Contains(text, "<@all>") { text = "<@all> " + text } text = normalizeAtPlaceholders(text, atOpenIDs, true) contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text}) content = string(contentJSON) } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), "content": content, } if atAll { toolArgs["atAll"] = true } if len(atOpenIDs) > 0 { toolArgs["atOpenDingTalkIds"] = atOpenIDs } return callMCPToolOnServer("im", "edit_message", toolArgs) }, } DeclareLeafMetadata(chatMessageEditCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "edit_message", CanonicalPath: "chat.edit_message", CLIPath: "chat message edit", PrimaryCLIPath: "chat message edit", }, Description: "编辑当前用户已发送消息的 Markdown 内容", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI builds or accepts message content and calls im/edit_message, which is absent from the pinned MCP metadata snapshot.", }, Selection: contract.SelectionSpec{ AgentSummary: "编辑当前用户已发送消息的 Markdown 内容", UseWhen: []string{"已有会话 openConversationId 和消息 openMessageId,需要更正已发送消息的标题、正文或 @ 信息"}, AvoidWhen: []string{"发送新消息应使用 chat message send;撤回消息应使用 chat message recall"}, Examples: []string{"dws chat message edit --conversation-id --message-id --text \"更新后的内容\""}, }, Parameters: []contract.ParamDecl{ {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"}, {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "content", Property: "content", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "text", Property: "text", Required: boolPtr(false)}, {Name: "title", Property: "title", Required: boolPtr(false)}, }, }, }) chatMessageReadStatusCmd := &cobra.Command{ Use: "read-status", Short: "查询消息的已读/未读状态", Long: `查询指定会话中消息的已读/未读状态(仅消息发送者可查询自己发出的消息)。--conversation-id 指定会话 openConversationId(群聊或单聊均可),--message-id 指定消息 ID(由 dws chat message list 返回的 openMessageId,必须是当前用户发送的消息)。目标用户 userId 使用 --user/--users;目标用户 openDingTalkId 使用 --target-open-dingtalk-ids;不传目标用户则返回所有接收者的状态。`, Example: ` dws chat message read-status --conversation-id --message-id dws chat message read-status --conversation-id --message-id --user userId1,userId2 dws chat message read-status --conversation-id --message-id --users userId1,userId2 dws chat message read-status --conversation-id --message-id --target-open-dingtalk-ids openDingTalkId1,openDingTalkId2 # 查询会话 ID: dws chat search --query "群名" # 查询 openMessageId: dws chat message list --conversation-id --time "2025-03-01 00:00:00" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { conversationID, err := requireChatConversationID(cmd) if err != nil { return err } messageID, err := chatMessageID(cmd) if err != nil { return err } toolArgs := map[string]any{ "openConversationId": conversationID, "openMessageId": messageID, } if usersStr := flagOrFallback(cmd, "users", "user", "userId"); usersStr != "" { appendChatIDArgs(toolArgs, parseCSVValues(usersStr), "targetUserIds", "targetOpenDingTalkIds") } if usersStr, _ := cmd.Flags().GetString("target-open-dingtalk-ids"); usersStr != "" { appendChatIDArgs(toolArgs, parseCSVValues(usersStr), "targetUserIds", "targetOpenDingTalkIds") } return callMCPToolOnServer("im", "query_msg_read_status", toolArgs) }, } DeclareLeafMetadata(chatMessageReadStatusCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "query_msg_read_status", CanonicalPath: "chat.query_msg_read_status", CLIPath: "chat message read-status", PrimaryCLIPath: "chat message read-status", }, Description: "查询指定消息的已读状态和人员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "query_msg_read_status"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定消息的已读状态和人员", UseWhen: []string{"已知会话 ID 与消息 ID,需要核对阅读情况时"}, AvoidWhen: []string{"查看哪些会话未读时使用 chat message list-unread-conversations"}, Examples: []string{"dws chat message read-status --conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, {Name: "target-open-dingtalk-ids", Property: "targetOpenDingTalkIds"}, }, }, }) chatSearchCommonCmd := &cobra.Command{ Use: "search-common", Short: "搜索共同群(查询指定人共同所在的群聊)", Long: `根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, Example: ` dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0 dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0 dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor `, RunE: runChatSearchCommon, } DeclareLeafMetadata(chatSearchCommonCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_common_groups", CanonicalPath: "chat.search_common_groups", CLIPath: "chat search-common", PrimaryCLIPath: "chat search-common", }, Description: "查询指定人员共同所在的群聊", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "search_common_groups"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定人员共同所在的群聊", UseWhen: []string{"需要找两人或多人共同群聊时"}, AvoidWhen: []string{"按群名称搜索时使用 chat search"}, Examples: []string{"dws chat search-common --nicks \"张三,李四\" --match-mode all --limit 20"}, }, }, }) chatMessageSearchCommonCmd := &cobra.Command{ Use: "search-common", Short: "搜索共同群", Hidden: true, RunE: runChatSearchCommon, } // ── bot 子命令 ──────────────────────────────────────────── chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE} chatBotSearchCmd := &cobra.Command{ Use: "search", Short: "搜索【我自己创建】的机器人(仅本人创建的,不含他人/官方机器人)", Long: `搜索【当前登录用户自己创建】的机器人,按 robotName 模糊匹配 + 页码分页。 如何在 search 与 find 之间选择: - search:用户说"我创建的""我的""我自己的""我做的"机器人 → 用 search - find :用户说"搜索机器人""找一个机器人""所有可用机器人""帮我找 XXX 机器人" (不限范围,包含他人/官方)→ 用 find(dws chat bot find) 注意:search 没有 openDingTalkId,如果需要给机器人发单聊消息请用 find。`, Example: ` # "搜一下我创建的机器人" / "我自己的机器人有哪些" dws chat bot search --page 1 dws chat bot search --page 1 --size 10 --name "日报"`, RunE: func(cmd *cobra.Command, args []string) error { page, _ := cmd.Flags().GetInt("page") toolArgs := map[string]any{ "currentPage": page, } size, _ := cmd.Flags().GetInt("size") if size == 0 { size, _ = cmd.Flags().GetInt("limit") } if size > 0 { toolArgs["pageSize"] = size } if v, _ := cmd.Flags().GetString("name"); v != "" { toolArgs["robotName"] = v } return callMCPToolOnServer("bot", "search_my_robots", toolArgs) }, } DeclareLeafMetadata(chatBotSearchCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_my_robots", CanonicalPath: "chat.search_my_robots", CLIPath: "chat bot search", PrimaryCLIPath: "chat bot search", }, Description: "搜索我创建的企业机器人并提取 robot-code", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "search_my_robots"}, }, Selection: contract.SelectionSpec{ AgentSummary: "搜索我创建的企业机器人并提取 robot-code", UseWhen: []string{"需要我自己创建的机器人及其 robot-code"}, AvoidWhen: []string{"搜索全部可用企业机器人时使用 chat bot find"}, Examples: []string{"dws chat bot search --page 1 --size 10 --name \"日报\""}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "robotName"}, {Name: "page", Property: "currentPage"}, {Name: "size", Property: "pageSize"}, }, }, }) // group 子命令 flags chatGroupCreateCmd.Flags().String("name", "", "群名称 (必填)") _ = chatGroupCreateCmd.MarkFlagRequired("name") chatGroupCreateCmd.Flags().String("users", "", "成员 userId 或 openDingTalkId(可混传),逗号分隔 (必填)") _ = chatGroupCreateCmd.MarkFlagRequired("users") chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)") chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈") chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)") _ = chatGroupMembersCmd.MarkFlagRequired("id") chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始") chatGroupMembersAddBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)") _ = chatGroupMembersAddBotCmd.MarkFlagRequired("robot-code") chatGroupMembersAddBotCmd.Flags().String("id", "", "群聊 openConversationId (必填)") _ = chatGroupMembersAddBotCmd.MarkFlagRequired("id") chatGroupRenameCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)") _ = chatGroupRenameCmd.MarkFlagRequired("id") chatGroupRenameCmd.Flags().String("name", "", "修改后的群名称 (必填)") _ = chatGroupRenameCmd.MarkFlagRequired("name") chatGroupMemberAddCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)") _ = chatGroupMemberAddCmd.MarkFlagRequired("id") chatGroupMemberAddCmd.Flags().String("users", "", "要添加的用户 userId 或 openDingTalkId(可混传),逗号分隔 (必填)") _ = chatGroupMemberAddCmd.MarkFlagRequired("users") chatGroupMemberRemoveCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)") _ = chatGroupMemberRemoveCmd.MarkFlagRequired("id") chatGroupMemberRemoveCmd.Flags().String("users", "", "要移除的用户 userId 列表,逗号分隔 (必填)") _ = chatGroupMemberRemoveCmd.MarkFlagRequired("users") chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd) chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd) chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd) // message 子命令 flags chatMessageListCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(群聊时必填)") chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)") chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认上海时间当前时间)") chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)") chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)") _ = chatMessageListCmd.Flags().MarkHidden("forward") chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制") chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListCmd.Flags().MarkHidden("size") cli.AnnotateRuntimeConstraints(chatMessageListCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, RequireOneOf: [][]string{{"group", "user", "open-dingtalk-id"}}, }) chatMessageListDirectCmd.Flags().String("user", "", "对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)") chatMessageListDirectCmd.Flags().String("open-dingtalk-id", "", "对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)") chatMessageListDirectCmd.Flags().String("time", "", "开始时间,格式 yyyy-MM-dd HH:mm:ss (必填)") chatMessageListDirectCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉") chatMessageListDirectCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older") _ = chatMessageListDirectCmd.Flags().MarkHidden("forward") chatMessageListDirectCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListDirectCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListDirectCmd.Flags().MarkHidden("size") chatMessageSendCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(群聊时必填)") chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)") chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)") corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{ Name: "content", Usage: "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)", Aliases: []string{"text"}, }}) for _, alias := range []string{"body", "message", "markdown"} { chatMessageSendCmd.Flags().String(alias, "", "--content 的兼容别名") _ = chatMessageSendCmd.Flags().MarkHidden(alias) } chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>") chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>") chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)") chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file;image 仅接受已有 mediaId)") chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)") chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)") chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)") chatMessageSendCmd.Flags().String("map-thumbnail-url", "", "位置消息地图缩略图 mediaId,形如 @mediaId(msgType=location 时必填)") chatMessageSendCmd.Flags().String("contact-id", "", "联系人名片 openDingTalkId(msgType=profile 时必填)") chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)") chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)") chatMessageSendCmd.Flags().String("file-name", "", "文件名") chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名") corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{ Name: "file", Usage: "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)", Aliases: []string{"file-path"}, }}) chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节") _ = chatMessageSendCmd.Flags().MarkHidden("dentry-id") _ = chatMessageSendCmd.Flags().MarkHidden("space-id") _ = chatMessageSendCmd.Flags().MarkHidden("file-name") _ = chatMessageSendCmd.Flags().MarkHidden("file-type") _ = chatMessageSendCmd.Flags().MarkHidden("file-size") chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)") corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{ Name: "idempotency-key", Usage: "幂等键,相同 key 在 24h 内不会重复发送(可选)", Aliases: []string{"uuid"}, }}) cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat") cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, RequireOneOf: [][]string{{"group", "user", "open-dingtalk-id"}}, }) cli.AnnotateRuntimePositionals(chatMessageSendCmd, contract.RuntimeSchemaPositional{ Name: "content", Type: "string", Description: "消息内容(也可使用 --content;富媒体消息可省略)", Required: false, Index: 0, }) cli.AnnotateRuntimeFlagEnum(chatMessageSendCmd, "msg-type", "image", "file", "audio", "video") cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file", "file-path") chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)") _ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code") chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)") chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)") chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)") chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送普通 Markdown 时必填;引用回复省略时从正文生成)") chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)") chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)") chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)") chatMessageSendByBotCmd.Flags().String("at-user-ids", "", "@指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选),--text 中需包含 @userId 对应文本") chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)") chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)") chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false") chatMessageSendByBotCmd.Flags().String("reply", "", "被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)") chatMessageSendByBotCmd.Flags().String("ref-sender", "", "被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)") chatMessageSendByBotCmd.MarkFlagsRequiredTogether("reply", "ref-sender") cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "users"}}, RequireOneOf: [][]string{{"group", "users"}}, RequireTogether: [][]string{{"reply", "ref-sender"}}, }) cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path") chatMessageRecallByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)") _ = chatMessageRecallByBotCmd.MarkFlagRequired("robot-code") chatMessageRecallByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊撤回时必填)") chatMessageRecallByBotCmd.Flags().String("keys", "", "消息 processQueryKey 列表,逗号分隔 (必填)") _ = chatMessageRecallByBotCmd.MarkFlagRequired("keys") chatMessageSendByWebhookCmd.Flags().String("token", "", "Webhook Token (必填)") _ = chatMessageSendByWebhookCmd.MarkFlagRequired("token") chatMessageSendByWebhookCmd.Flags().String("title", "", "消息标题 (必填)") _ = chatMessageSendByWebhookCmd.MarkFlagRequired("title") corecmd.RegisterFlags(chatMessageSendByWebhookCmd, []corecmd.FlagSpec{{ Name: "content", Usage: "消息内容 (必填)", Aliases: []string{"text"}, }}) _ = chatMessageSendByWebhookCmd.MarkFlagRequired("content") chatMessageSendByWebhookCmd.Flags().Bool("at-all", false, "@ 所有人") chatMessageSendByWebhookCmd.Flags().String("at-mobiles", "", "@ 指定手机号,逗号分隔") chatMessageSendByWebhookCmd.Flags().String("at-users", "", "@ 指定用户,逗号分隔") chatBotSearchCmd.Flags().Int("page", 1, "页码,从1开始") chatBotSearchCmd.Flags().Int("size", 0, "每页条数 (默认50)") chatBotSearchCmd.Flags().Int("limit", 0, "--size 的别名") _ = chatBotSearchCmd.Flags().MarkHidden("limit") chatBotSearchCmd.Flags().String("name", "", "按名称搜索") chatMessageListTopicRepliesCmd.Flags().String("conversation-id", "", "群会话 openconversationId (必填)") _ = chatMessageListTopicRepliesCmd.MarkFlagRequired("conversation-id") chatMessageListTopicRepliesCmd.Flags().String("topic-id", "", "话题 ID,由 dws chat message list 返回 (必填)") _ = chatMessageListTopicRepliesCmd.MarkFlagRequired("topic-id") chatMessageListTopicRepliesCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)") chatMessageListTopicRepliesCmd.Flags().Int("limit", 50, "返回数量(默认 50)") chatMessageListTopicRepliesCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐,默认 older)") chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)") _ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward") chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)") chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListAllCmd.Flags().MarkHidden("size") chatMessageListAllCmd.Flags().String("cursor", "0", "分页游标(首页传 \"0\",后续从响应中获取)") AddPagedMCPFlags(chatMessageListAllCmd) // list-by-sender flags chatMessageListBySenderCmd.Flags().String("sender-user-id", "", "发送者 userId(与 --sender-open-dingtalk-id 二选一)") chatMessageListBySenderCmd.Flags().String("sender", "", "--sender-user-id 的旧版别名") _ = chatMessageListBySenderCmd.Flags().MarkHidden("sender") chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)") chatMessageListBySenderCmd.Flags().String("user", "", "") _ = chatMessageListBySenderCmd.Flags().MarkHidden("user") chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListBySenderCmd.Flags().MarkHidden("size") chatMessageListBySenderCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") AddPagedMCPFlags(chatMessageListBySenderCmd) // list-mentions flags chatMessageListMentionsCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(可选,不传则查全部)") chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListMentionsCmd.Flags().MarkHidden("size") chatMessageListMentionsCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") AddPagedMCPFlags(chatMessageListMentionsCmd) // list-focused flags chatMessageListFocusedCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListFocusedCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)") AddPagedMCPFlags(chatMessageListFocusedCmd) // list-top-conversations flags chatMessageListTopConversationsCmd.Flags().Int("limit", 1000, "每页返回数量(默认 1000)") chatMessageListTopConversationsCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)") chatMessageListTopConversationsCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的会话(默认 false)") chatMessageListUnreadConversationsCmd.Flags().Int("count", 0, "返回未读会话条数(可选,不传则使用服务端默认值)") chatMessageListUnreadConversationsCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的会话(默认 false)") // message search flags chatMessageSearchCmd.Flags().String("query", "", "搜索关键词 (必填)") chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名") _ = chatMessageSearchCmd.Flags().MarkHidden("keyword") chatMessageSearchCmd.Flags().String("conversation-id", "", "群聊 openconversation_id(可选,不传则搜索所有会话)") chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchCmd.Flags().MarkHidden("size") chatMessageSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") AddPagedMCPFlags(chatMessageSearchCmd) // read-status flags (主 flag 为 --conversation-id,因为支持群聊和单聊) chatMessageReadStatusCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,群聊或单聊均可)") _ = chatMessageReadStatusCmd.MarkFlagRequired("conversation-id") chatMessageReadStatusCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatMessageReadStatusCmd.Flags().MarkHidden("group") chatMessageReadStatusCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMessageReadStatusCmd.Flags().MarkHidden("id") chatMessageReadStatusCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMessageReadStatusCmd.Flags().MarkHidden("chat") chatMessageReadStatusCmd.Flags().String("message-id", "", "消息 openMessageId,由 chat message list 返回 (必填)") _ = chatMessageReadStatusCmd.MarkFlagRequired("message-id") chatMessageReadStatusCmd.Flags().String("user", "", "目标用户 userId,支持逗号分隔(可选,不传则查所有接收者)") chatMessageReadStatusCmd.Flags().String("users", "", "目标用户 userId 列表,逗号分隔(可选,不传则查所有接收者)") chatMessageReadStatusCmd.Flags().String("userId", "", "--user 的别名") _ = chatMessageReadStatusCmd.Flags().MarkHidden("userId") chatMessageReadStatusCmd.Flags().String("target-open-dingtalk-ids", "", "目标用户 openDingTalkId 列表,逗号分隔(可选,不传则查所有接收者)") // search-common flags chatSearchCommonCmd.Flags().String("nicks", "", "要搜索的昵称列表,逗号分隔 (必填)") _ = chatSearchCommonCmd.MarkFlagRequired("nicks") chatSearchCommonCmd.Flags().String("match-mode", "AND", "匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)") chatSearchCommonCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") chatSearchCommonCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatSearchCommonCmd.Flags().MarkHidden("size") chatSearchCommonCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") chatSearchCommonCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") chatMessageSearchCommonCmd.Flags().String("nicks", "", "要搜索的昵称列表,逗号分隔 (必填)") _ = chatMessageSearchCommonCmd.MarkFlagRequired("nicks") chatMessageSearchCommonCmd.Flags().String("match-mode", "AND", "匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)") chatMessageSearchCommonCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") chatMessageSearchCommonCmd.Flags().Int("size", 0, "") _ = chatMessageSearchCommonCmd.Flags().MarkHidden("size") chatMessageSearchCommonCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") chatMessageSearchCommonCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") chatMessageSearchCommonCmd.Flags().String("group", "", "") _ = chatMessageSearchCommonCmd.Flags().MarkHidden("group") // search-advanced flags chatMessageSearchAdvancedCmd.Flags().String("query", "", "搜索关键词(可选)") chatMessageSearchAdvancedCmd.Flags().String("keyword", "", "--query 的别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("keyword") chatMessageSearchAdvancedCmd.Flags().String("user", "", "发送者 userId,支持逗号分隔(可选)") chatMessageSearchAdvancedCmd.Flags().String("users", "", "发送者 userId 列表,逗号分隔(可选)") chatMessageSearchAdvancedCmd.Flags().String("userId", "", "--user 的别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("userId") chatMessageSearchAdvancedCmd.Flags().String("sender-ids", "", "发送者 openDingTalkId 列表,逗号分隔(可选)") chatMessageSearchAdvancedCmd.Flags().String("senders", "", "--sender-ids 的旧版别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("senders") chatMessageSearchAdvancedCmd.Flags().String("sender", "", "--sender-ids 的旧版别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("sender") chatMessageSearchAdvancedCmd.Flags().Bool("at-me", false, "只搜索 @我 的消息(可选,默认 false)") chatMessageSearchAdvancedCmd.Flags().String("at-ids", "", "@指定人的 openDingTalkId 列表,逗号分隔(可选)") chatMessageSearchAdvancedCmd.Flags().String("conversation-ids", "", "会话 openConversationId 列表,逗号分隔(可选,群聊或单聊均可,不传则搜索所有会话)") chatMessageSearchAdvancedCmd.Flags().String("groups", "", "--conversation-ids 的别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("groups") chatMessageSearchAdvancedCmd.Flags().String("group", "", "") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("group") chatMessageSearchAdvancedCmd.Flags().String("message-type", "", "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)") chatMessageSearchAdvancedCmd.Flags().Bool("only-robot", false, "只搜索机器人消息(可选;显式传 false 时也会传给下层)") chatMessageSearchAdvancedCmd.Flags().Bool("only-robot-messages", false, "--only-robot 的别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("only-robot-messages") chatMessageSearchAdvancedCmd.Flags().String("conversation-type", "", "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)") chatMessageSearchAdvancedCmd.Flags().String("search-conv-type", "", "--conversation-type 的别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("search-conv-type") chatMessageSearchAdvancedCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选)") chatMessageSearchAdvancedCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选)") chatMessageSearchAdvancedCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\")") chatMessageSearchAdvancedCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") chatMessageSearchAdvancedCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("size") AddPagedMCPFlags(chatMessageSearchAdvancedCmd) // query-send-status flags chatMessageQuerySendStatusCmd.Flags().String("open-task-id", "", "消息发送任务 ID (必填)") _ = chatMessageQuerySendStatusCmd.MarkFlagRequired("open-task-id") // recall flags chatMessageRecallCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") chatMessageRecallCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatMessageRecallCmd.Flags().MarkHidden("group") chatMessageRecallCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMessageRecallCmd.Flags().MarkHidden("id") chatMessageRecallCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMessageRecallCmd.Flags().MarkHidden("chat") chatMessageRecallCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageRecallCmd.MarkFlagRequired("message-id") // edit flags chatMessageEditCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") chatMessageEditCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatMessageEditCmd.Flags().MarkHidden("group") chatMessageEditCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMessageEditCmd.Flags().MarkHidden("id") chatMessageEditCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMessageEditCmd.Flags().MarkHidden("chat") chatMessageEditCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageEditCmd.MarkFlagRequired("message-id") chatMessageEditCmd.Flags().String("text", "", "编辑后的 Markdown 正文;与 --content 二选一") chatMessageEditCmd.Flags().String("title", "", "消息标题;配合 --text 使用,未传时从正文自动生成") chatMessageEditCmd.Flags().String("content", "", "完整 Markdown content JSON;与 --text 二选一") chatMessageEditCmd.Flags().Bool("at-all", false, "是否 @所有人;正文未包含 <@all> 时自动补到开头") chatMessageEditCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔") cli.AnnotateRuntimeRequiredFlags(chatMessageEditCmd, "conversation-id") cli.AnnotateRuntimeConstraints(chatMessageEditCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"text", "content"}}, RequireOneOf: [][]string{{"text", "content"}}, }) // 别名注册: --conversation-id/--id/--chat → --group (chat message 子命令) groupAliasCmds := []*cobra.Command{ chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageListTopicRepliesCmd, chatMessageListMentionsCmd, chatMessageSearchCmd, } for _, c := range groupAliasCmds { c.Flags().String("group", "", "--conversation-id 的别名") _ = c.Flags().MarkHidden("group") if c.Flags().Lookup("id") == nil { c.Flags().String("id", "", "--group 的别名") _ = c.Flags().MarkHidden("id") } c.Flags().String("chat", "", "--group 的别名") _ = c.Flags().MarkHidden("chat") } // conversation-info: 获取会话基础信息 chatConversationInfoCmd := &cobra.Command{ Use: "conversation-info", Short: "获取会话基础信息", Long: `获取指定会话的基础信息。 发送本地文件消息请优先使用 dws chat message send --msg-type file --file <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`, Example: ` dws chat conversation-info --conversation-id --format json dws chat conversation-info --user --format json dws chat conversation-info --open-dingtalk-id --format json`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") rawOpenDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") rawUserID := flagOrFallback(cmd, "user", "userId") specified := 0 for _, value := range []string{groupID, rawUserID, rawOpenDingTalkID} { if value != "" { specified++ } } if specified > 1 { return fmt.Errorf("--group, --user and --open-dingtalk-id are mutually exclusive, specify exactly one") } if specified == 0 { return fmt.Errorf("--group, --user or --open-dingtalk-id is required") } userID := rawUserID openDingTalkID := rawOpenDingTalkID if openDingTalkID != "" { if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { return err } } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" } toolArgs := map[string]any{} if groupID != "" { toolArgs["openConversationId"] = groupID } if userID != "" { // 服务端 get_conversation_info 单聊只认 openDingTalkId(传 userId 键会被 // 忽略并报「openCid、cid、peerUid不能同时为空」)。这里把 --user 的 userId // 先解析成 openDingTalkId 再走与 --open-dingtalk-id 相同的通路。 resolved, rerr := resolveOpenDingTalkID(context.Background(), userID) if rerr != nil { return rerr } toolArgs["openDingTalkId"] = resolved } if openDingTalkID != "" { toolArgs["openDingTalkId"] = openDingTalkID } return callMCPToolOnServer("chat", "get_conversation_info", toolArgs) }, } DeclareLeafMetadata(chatConversationInfoCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_conversation_info", CanonicalPath: "chat.get_conversation_info", CLIPath: "chat conversation-info", PrimaryCLIPath: "chat conversation-info", }, Description: "获取群聊或单聊会话的详细信息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "get_conversation_info"}, }, Selection: contract.SelectionSpec{ AgentSummary: "获取群聊或单聊会话的详细信息", UseWhen: []string{"已知群 ID 或用户标识并需要解析会话详情时"}, AvoidWhen: []string{"按群名查找会话时使用 chat search"}, Examples: []string{"dws chat conversation-info --group --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "open-dingtalk-id", Property: "openDingTalkId"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "user", Property: "openDingTalkId"}, }, }, }) chatConversationInfoCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)") chatConversationInfoCmd.Flags().String("group", "", "--conversation-id 的别名") chatConversationInfoCmd.Flags().String("id", "", "--group 的别名") chatConversationInfoCmd.Flags().String("chat", "", "--group 的别名") _ = chatConversationInfoCmd.Flags().MarkHidden("group") _ = chatConversationInfoCmd.Flags().MarkHidden("id") _ = chatConversationInfoCmd.Flags().MarkHidden("chat") chatConversationInfoCmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)") chatConversationInfoCmd.Flags().String("userId", "", "--user 的别名") _ = chatConversationInfoCmd.Flags().MarkHidden("userId") chatConversationInfoCmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)") cli.AnnotateRuntimeConstraints(chatConversationInfoCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, RequireOneOf: [][]string{{"group", "user", "open-dingtalk-id"}}, }) // ── file 子命令(会话文件上传,不暴露 spaceId)─────────────── chatFileCmd := &cobra.Command{ Use: "file", Short: "会话文件上传(已下线)", Hidden: true, RunE: groupRunE, } chatFileUploadCmd := &cobra.Command{ Use: "upload", Short: "上传本地文件或 URL 文件到会话文件空间(已下线)", Hidden: true, Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。 发送本地文件消息请改用 chat message send --msg-type file --file;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`, Example: ` dws chat message send --conversation-id --msg-type file --file ./report.pdf --format json dws chat message send --open-dingtalk-id --msg-type file --file ./report.pdf --format json`, RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file <本地路径>") }, } chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)") chatFileUploadCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatFileUploadCmd.Flags().MarkHidden("group") chatFileUploadCmd.Flags().String("id", "", "--group 的别名") _ = chatFileUploadCmd.Flags().MarkHidden("id") chatFileUploadCmd.Flags().String("chat", "", "--group 的别名") _ = chatFileUploadCmd.Flags().MarkHidden("chat") chatFileUploadCmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)") chatFileUploadCmd.Flags().String("userId", "", "--user 的别名") _ = chatFileUploadCmd.Flags().MarkHidden("userId") chatFileUploadCmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)") chatFileUploadCmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)") chatFileUploadCmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)") chatFileUploadCmd.Flags().String("file-name", "", "文件名(可选,本地文件默认取文件名,URL 默认从 URL 推断)") chatFileUploadCmd.Flags().String("md5", "", "文件 MD5(可选,本地文件不传时自动计算)") chatFileUploadCmd.Flags().String("uuid", "", "幂等 UUID(可选)") chatFileCmd.AddCommand(chatFileUploadCmd) // ── category 子命令(会话分组,走 IM MCP)─────────────────── chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE} chatCategoryListCmd := &cobra.Command{ Use: "list", Short: "获取用户自定义会话分组", Example: ` dws chat category list # 返回当前用户的所有自定义会话分组`, RunE: func(cmd *cobra.Command, args []string) error { return callMCPToolOnServer("im", "list_user_define_conv_categories", map[string]any{}) }, } DeclareLeafMetadata(chatCategoryListCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_user_define_conv_categories", CanonicalPath: "chat.list_user_define_conv_categories", CLIPath: "chat category list", PrimaryCLIPath: "chat category list", }, Description: "列出当前用户的自定义会话分组", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_user_define_conv_categories"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出当前用户的自定义会话分组", UseWhen: []string{"需要取得分组 ID 或浏览分组配置时"}, AvoidWhen: []string{"需要查看某个分组内会话时使用 chat category list-conversations"}, Examples: []string{"dws chat category list"}, }, }, }) chatCategoryConvsCmd := &cobra.Command{ Use: "list-conversations", Short: "拉取指定自定义会话分组下的会话", Example: ` dws chat category list-conversations --category-id <分组ID> # 分组ID 可通过 dws chat category list 获取`, RunE: func(cmd *cobra.Command, args []string) error { categoryId, _ := cmd.Flags().GetInt("category-id") toolArgs := map[string]any{ "categoryId": categoryId, } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPToolOnServer("im", "list_conversations_by_category", toolArgs) }, } DeclareLeafMetadata(chatCategoryConvsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_conversations_by_category", CanonicalPath: "chat.list_conversations_by_category", CLIPath: "chat category list-conversations", PrimaryCLIPath: "chat category list-conversations", }, Description: "列出指定会话分组中的会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_conversations_by_category"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出指定会话分组中的会话", UseWhen: []string{"已知分组 ID 并需要查看其中会话时"}, AvoidWhen: []string{"只需列出分组本身时使用 chat category list"}, Examples: []string{"dws chat category list-conversations --category-id 123"}, }, }, }) chatCategoryCreateCmd := &cobra.Command{ Use: "create", Short: "创建用户自定义会话分组", Example: ` dws chat category create --title "工作群" dws chat category create --title "项目组"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "title"); err != nil { return err } title, err := validatedConversationCategoryTitle(mustGetFlag(cmd, "title")) if err != nil { return err } return callMCPToolOnServer("im", "create_conv_category", map[string]any{ "title": title, }) }, } DeclareLeafMetadata(chatCategoryCreateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_conv_category", CanonicalPath: "chat." + "create_conv_category", CLIPath: "chat category create", PrimaryCLIPath: "chat category create", }, Description: "创建用户自定义会话分组", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_conv_category"}, }, Selection: contract.SelectionSpec{ AgentSummary: "创建用户自定义会话分组", UseWhen: []string{"需要新建一个手工维护的会话分组时"}, AvoidWhen: []string{"需要按规则自动归集会话时使用 chat category create-smart"}, Examples: []string{"dws chat category create --title \"工作群\""}, }, Parameters: []contract.ParamDecl{ {Name: "title", Property: "title", Required: boolPtr(true)}, }, }, }) chatCategoryDeleteCmd := &cobra.Command{ Use: "delete", Short: "删除用户自定义会话分组", Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。", Example: ` dws chat category delete --category-id <分组ID> # 分组ID 可通过 dws chat category list 获取`, RunE: func(cmd *cobra.Command, args []string) error { categoryId, _ := cmd.Flags().GetInt64("category-id") if categoryId == 0 { return fmt.Errorf("flag --category-id is required") } if !commandBoolFlag(cmd, "yes") { return apperrors.NewValidation( "删除会话分组不可逆;获得用户确认后加 --yes 执行", apperrors.WithReason("confirmation_required"), apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"), apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"), ) } return callMCPToolOnServer("im", "delete_conv_category", map[string]any{ "categoryId": categoryId, }) }, } DeclareLeafMetadata(chatCategoryDeleteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "delete_conv_category", CanonicalPath: "chat." + "delete_conv_category", CLIPath: "chat category delete", PrimaryCLIPath: "chat category delete", }, Description: "删除用户自定义会话分组", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "delete_conv_category"}, }, Selection: contract.SelectionSpec{ AgentSummary: "删除用户自定义会话分组", UseWhen: []string{"用户明确要删除某个自定义会话分组,且已确认不影响会话本身"}, AvoidWhen: []string{"只是从分组移出某个会话时使用 chat category remove-conv"}, Examples: []string{"dws chat category delete --category-id 123"}, }, Parameters: []contract.ParamDecl{ {Name: "category-id", Property: "categoryId", Required: boolPtr(true)}, {Name: "yes", Property: "", Required: boolPtr(false)}, }, }, }) chatCategoryRenameCmd := &cobra.Command{ Use: "rename", Short: "更新用户自定义会话分组的名称", Example: ` dws chat category rename --category-id <分组ID> --title "新名称" # 分组ID 可通过 dws chat category list 获取`, RunE: func(cmd *cobra.Command, args []string) error { categoryId, _ := cmd.Flags().GetInt64("category-id") if categoryId == 0 { return fmt.Errorf("flag --category-id is required") } if err := validateRequiredFlags(cmd, "title"); err != nil { return err } title, err := validatedConversationCategoryTitle(mustGetFlag(cmd, "title")) if err != nil { return err } return callMCPToolOnServer("im", "rename_conv_category", map[string]any{ "categoryId": categoryId, "title": title, }) }, } DeclareLeafMetadata(chatCategoryRenameCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "rename_conv_category", CanonicalPath: "chat." + "rename_conv_category", CLIPath: "chat category rename", PrimaryCLIPath: "chat category rename", }, Description: "更新用户自定义会话分组的名称", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "rename_conv_category"}, }, Selection: contract.SelectionSpec{ AgentSummary: "更新用户自定义会话分组的名称", UseWhen: []string{"需要给已有自定义会话分组改名时"}, AvoidWhen: []string{"需要新建分组时使用 chat category create"}, Examples: []string{"dws chat category rename --category-id 123 --title \"新名称\""}, }, Parameters: []contract.ParamDecl{ {Name: "category-id", Property: "categoryId", Required: boolPtr(true)}, {Name: "title", Property: "title", Required: boolPtr(true)}, }, }, }) chatCategoryAddConvCmd := &cobra.Command{ Use: "add-conv", Short: "将会话移动到指定的自定义分组中", Long: `将某个会话添加到一批用户自定义会话分组中。需指定会话 openConversationId 和目标分组 ID 列表。`, Example: ` dws chat category add-conv --conversation-id --category-ids 123,456 # 分组ID 可通过 dws chat category list 获取 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { return fmt.Errorf("flag --conversation-id is required") } if err := validateRequiredFlags(cmd, "category-ids"); err != nil { return err } categoryIds, err := parseCSVInt64(mustGetFlag(cmd, "category-ids")) if err != nil { return fmt.Errorf("--category-ids: %w", err) } return callMCPToolOnServer("im", "add_conv_to_categories", map[string]any{ "openConversationId": groupID, "categoryIds": categoryIds, }) }, } DeclareLeafMetadata(chatCategoryAddConvCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_conv_to_categories", CanonicalPath: "chat." + "add_conv_to_categories", CLIPath: "chat category add-conv", PrimaryCLIPath: "chat category add-conv", }, Description: "将会话加入一个或多个自定义会话分组", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_conv_to_categories"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将会话加入一个或多个自定义会话分组", UseWhen: []string{"已有会话 openConversationId 和目标 categoryId,需要把会话归入分组时"}, AvoidWhen: []string{"从分组移除会话时使用 chat category remove-conv"}, Examples: []string{"dws chat category add-conv --conversation-id --category-ids 123,456"}, }, Parameters: []contract.ParamDecl{ {Name: "category-ids", Property: "categoryIds", Required: boolPtr(true), InterfaceType: "array"}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) chatCategoryRemoveConvCmd := &cobra.Command{ Use: "remove-conv", Short: "将会话从指定的自定义分组中移出", Long: `将某个会话从一批用户自定义会话分组中移出。需指定会话 openConversationId 和目标分组 ID 列表。`, Example: ` dws chat category remove-conv --conversation-id --category-ids 123,456 # 分组ID 可通过 dws chat category list 获取 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { return fmt.Errorf("flag --group is required") } if err := validateRequiredFlags(cmd, "category-ids"); err != nil { return err } categoryIds, err := parseCSVInt64(mustGetFlag(cmd, "category-ids")) if err != nil { return fmt.Errorf("--category-ids: %w", err) } return callMCPToolOnServer("im", "remove_conv_from_categories", map[string]any{ "openConversationId": groupID, "categoryIds": categoryIds, }) }, } DeclareLeafMetadata(chatCategoryRemoveConvCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_conv_from_categories", CanonicalPath: "chat." + "remove_conv_from_categories", CLIPath: "chat category remove-conv", PrimaryCLIPath: "chat category remove-conv", }, Description: "将会话从一个或多个自定义会话分组移出", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_conv_from_categories"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将会话从一个或多个自定义会话分组移出", UseWhen: []string{"已有会话 openConversationId 和 categoryId,需要取消会话分组归属时"}, AvoidWhen: []string{"向分组加入会话时使用 chat category add-conv"}, Examples: []string{"dws chat category remove-conv --conversation-id --category-ids 123,456"}, }, Parameters: []contract.ParamDecl{ {Name: "category-ids", Property: "categoryIds", Required: boolPtr(true), InterfaceType: "array"}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) chatCategoryListByConvCmd := &cobra.Command{ Use: "list-by-conv", Short: "拉取指定会话所属的用户自定义会话分组", Long: `拉取指定会话所属的用户自定义会话分组。需指定会话 openConversationId。`, Example: ` dws chat category list-by-conv --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id"); err != nil { return err } return callMCPToolOnServer("im", "list_conv_categories_by_conv", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id"), }) }, } DeclareLeafMetadata(chatCategoryListByConvCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_conv_categories_by_conv", CanonicalPath: "chat.list_conv_categories_by_conv", CLIPath: "chat category list-by-conv", PrimaryCLIPath: "chat category list-by-conv", }, Description: "查询指定会话所属的自定义会话分组", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI maps a conversation locator to im/list_conv_categories_by_conv, which is absent from the pinned MCP metadata snapshot.", }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定会话所属的自定义会话分组", UseWhen: []string{"已有会话 openConversationId,需要反查该会话被放入了哪些自定义分组"}, AvoidWhen: []string{"列出全部自定义分组应使用 chat category list;按 categoryId 查详情应使用 chat category batch-info"}, Examples: []string{"dws chat category list-by-conv --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) chatCategoryBatchInfoCmd := &cobra.Command{ Use: "batch-info", Short: "批量拉取用户自定义会话分组信息", Long: `根据分组 ID 列表批量拉取用户自定义会话分组信息。分组 ID 使用逗号分隔。`, Example: ` dws chat category batch-info --category-ids 123,456 # 分组ID 可通过 dws chat category list 获取`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "category-ids"); err != nil { return err } categoryIDs, err := parseCSVInt64(mustGetFlag(cmd, "category-ids")) if err != nil { return fmt.Errorf("--category-ids: %w", err) } return callMCPToolOnServer("im", "get_conv_categories_info", map[string]any{ "categoryIds": categoryIDs, }) }, } DeclareLeafMetadata(chatCategoryBatchInfoCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_conv_categories_info", CanonicalPath: "chat.get_conv_categories_info", CLIPath: "chat category batch-info", PrimaryCLIPath: "chat category batch-info", }, Description: "按分组 ID 批量获取自定义会话分组详情", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI parses category IDs and calls im/get_conv_categories_info, which is absent from the pinned MCP metadata snapshot.", }, Selection: contract.SelectionSpec{ AgentSummary: "按分组 ID 批量获取自定义会话分组详情", UseWhen: []string{"已经有一个或多个会话分组 categoryId,需要批量读取分组信息"}, AvoidWhen: []string{"不知道 categoryId 时先用 chat category list;按会话反查所属分组应使用 chat category list-by-conv"}, Examples: []string{"dws chat category batch-info --category-ids 123,456"}, }, Parameters: []contract.ParamDecl{ {Name: "category-ids", Property: "categoryIds", Required: boolPtr(true), InterfaceType: "array"}, }, }, }) // ── group get-by-group-id(走 IM MCP)───────────────────────── chatGroupInfoByIdCmd := &cobra.Command{ Use: "get-by-group-id", Short: "根据群号获取群聊信息", Example: ` dws chat group get-by-group-id --group-id 12345678 # 群号为数字类型的群ID`, RunE: func(cmd *cobra.Command, args []string) error { groupId, _ := cmd.Flags().GetInt64("group-id") return callMCPToolOnServer("im", "get_conv_info_by_group_id", map[string]any{ "groupId": groupId, }) }, } DeclareLeafMetadata(chatGroupInfoByIdCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_conv_info_by_group_id", CanonicalPath: "chat.get_conv_info_by_group_id", CLIPath: "chat group get-by-group-id", PrimaryCLIPath: "chat group get-by-group-id", }, Description: "把数字群号解析为群聊信息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "get_conv_info_by_group_id"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把数字群号解析为群聊信息", UseWhen: []string{"用户只提供数字群号且需要 openConversationId 时"}, AvoidWhen: []string{"已经持有 openConversationId 时直接使用目标命令"}, Examples: []string{"dws chat group get-by-group-id --group-id 12345678"}, }, }, }) // ── message 新增命令(批量查消息、emoji 表情、文字表情)───── chatMessageListByIdsCmd := &cobra.Command{ Use: "list-by-ids", Short: "根据消息 ID 批量查询消息", Example: ` dws chat message list-by-ids --msg-ids msgId1,msgId2,msgId3 # 最多传 50 条消息 ID`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "msg-ids"); err != nil { return err } msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids")) if len(msgIds) > 50 { return fmt.Errorf("--msg-ids 最多支持 50 条,当前 %d 条", len(msgIds)) } return callMCPToolOnServer("im", "list_messages_by_ids", map[string]any{ "openMsgIds": msgIds, }) }, } DeclareLeafMetadata(chatMessageListByIdsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_messages_by_ids", CanonicalPath: "chat.list_messages_by_ids", CLIPath: "chat message list-by-ids", PrimaryCLIPath: "chat message list-by-ids", }, Description: "按消息 ID 批量获取消息详情", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_messages_by_ids"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按消息 ID 批量获取消息详情", UseWhen: []string{"已经持有一组 msgId 并需要精确取回消息时"}, AvoidWhen: []string{"只有关键词或时间范围时使用 search 或 list 命令"}, Examples: []string{"dws chat message list-by-ids --msg-ids msgId1,msgId2"}, }, Parameters: []contract.ParamDecl{ {Name: "msg-ids", Property: "openMsgIds"}, }, }, }) chatMessageListByIdsCmd.Flags().String("msg-ids", "", "消息 ID 列表,逗号分隔,最多 50 条 (必填)") _ = chatMessageListByIdsCmd.MarkFlagRequired("msg-ids") chatMessageAddEmojiCmd := &cobra.Command{ Use: "add-emoji", Short: "对消息添加 emoji 表情回应", Example: ` dws chat message add-emoji --conversation-id --message-id --emoji "赞" # 查询会话 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } if err := validateRequiredFlags(cmd, "emoji"); err != nil { return err } return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emojiName": mustGetFlag(cmd, "emoji"), }) }, } DeclareLeafMetadata(chatMessageAddEmojiCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_emoji_reaction", CanonicalPath: "chat.add_emoji_reaction", CLIPath: "chat message add-emoji", PrimaryCLIPath: "chat message add-emoji", }, Description: "给指定消息添加表情回应", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_emoji_reaction"}, }, Selection: contract.SelectionSpec{ AgentSummary: "给指定消息添加表情回应", UseWhen: []string{"需要对已有消息添加一个 emoji reaction 时"}, AvoidWhen: []string{"发送文本消息或文字表情时不要使用"}, Examples: []string{"dws chat message add-emoji --conversation-id --message-id --emoji \"赞\""}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "emoji", Property: "emojiName"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMsgId"}, {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) chatMessageAddEmojiCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMessageAddEmojiCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("chat", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") chatMessageAddEmojiCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageAddEmojiCmd.Flags().String("emoji", "", "emoji 表情名称 (必填)") cli.AnnotateRuntimeConstraints(chatMessageAddEmojiCmd, cli.RuntimeSchemaConstraints{ RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, }) chatMessageRemoveEmojiCmd := &cobra.Command{ Use: "remove-emoji", Short: "移除消息的 emoji 表情回应", Example: ` dws chat message remove-emoji --conversation-id --message-id --emoji "赞" # 查询会话 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } if err := validateRequiredFlags(cmd, "emoji"); err != nil { return err } return callMCPToolOnServer("im", "remove_emoji_reaction", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emojiName": mustGetFlag(cmd, "emoji"), }) }, } DeclareLeafMetadata(chatMessageRemoveEmojiCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_emoji_reaction", CanonicalPath: "chat.remove_emoji_reaction", CLIPath: "chat message remove-emoji", PrimaryCLIPath: "chat message remove-emoji", }, Description: "移除指定消息上的表情回应", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_emoji_reaction"}, }, Selection: contract.SelectionSpec{ AgentSummary: "移除指定消息上的表情回应", UseWhen: []string{"需要取消此前添加的 emoji reaction 时"}, AvoidWhen: []string{"移除文字表情时使用 chat message remove-text-emotion"}, Examples: []string{"dws chat message remove-emoji --conversation-id --message-id --emoji \"赞\""}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "emoji", Property: "emojiName"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMsgId"}, {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) chatMessageRemoveEmojiCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMessageRemoveEmojiCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("chat", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") chatMessageRemoveEmojiCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageRemoveEmojiCmd.Flags().String("emoji", "", "emoji 表情名称 (必填)") cli.AnnotateRuntimeConstraints(chatMessageRemoveEmojiCmd, cli.RuntimeSchemaConstraints{ RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, }) chatMessageAddTextEmotionCmd := &cobra.Command{ Use: "add-text-emotion", Short: "对消息添加文字表情回应", Example: ` dws chat message add-text-emotion --conversation-id --message-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } if err := validateRequiredFlags(cmd, "emotion-id", "emotion-name", "text", "background-id"); err != nil { return err } return callMCPToolOnServer("im", "add_text_emotion", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), "backgroundId": mustGetFlag(cmd, "background-id"), }) }, } DeclareLeafMetadata(chatMessageAddTextEmotionCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_text_emotion", CanonicalPath: "chat.add_text_emotion", CLIPath: "chat message add-text-emotion", PrimaryCLIPath: "chat message add-text-emotion", }, Description: "给指定消息添加已定义的文字表情", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_text_emotion"}, }, Selection: contract.SelectionSpec{ AgentSummary: "给指定消息添加已定义的文字表情", UseWhen: []string{"已有文字表情定义并要附加到消息时"}, AvoidWhen: []string{"需要先创建文字表情资源时使用 chat message create-text-emotion"}, Examples: []string{"dws chat message add-text-emotion --conversation-id --message-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMsgId"}, {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) chatMessageAddTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMessageAddTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") chatMessageAddTextEmotionCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageAddTextEmotionCmd.Flags().String("emotion-id", "", "表情 ID (必填,通过 create-text-emotion 获取)") chatMessageAddTextEmotionCmd.Flags().String("emotion-name", "", "表情名称 (必填)") chatMessageAddTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)") chatMessageAddTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)") cli.AnnotateRuntimeConstraints(chatMessageAddTextEmotionCmd, cli.RuntimeSchemaConstraints{ RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, }) chatMessageRemoveTextEmotionCmd := &cobra.Command{ Use: "remove-text-emotion", Short: "移除消息的文字表情回应", Example: ` dws chat message remove-text-emotion --conversation-id --message-id --emotion-id --emotion-name "赞" --text "nice" --background-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } if err := validateRequiredFlags(cmd, "emotion-id", "emotion-name", "text", "background-id"); err != nil { return err } return callMCPToolOnServer("im", "remove_text_emotion", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), "backgroundId": mustGetFlag(cmd, "background-id"), }) }, } DeclareLeafMetadata(chatMessageRemoveTextEmotionCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_text_emotion", CanonicalPath: "chat.remove_text_emotion", CLIPath: "chat message remove-text-emotion", PrimaryCLIPath: "chat message remove-text-emotion", }, Description: "移除指定消息上的文字表情回应", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_text_emotion"}, }, Selection: contract.SelectionSpec{ AgentSummary: "移除指定消息上的文字表情回应", UseWhen: []string{"需要取消已添加的文字表情时"}, AvoidWhen: []string{"移除普通 emoji reaction 时使用 chat message remove-emoji"}, Examples: []string{"dws chat message remove-text-emotion --conversation-id --message-id --emotion-id --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMsgId"}, {Name: "msg-id", Property: "openMsgId", Required: boolPtr(true)}, }, }, }) chatMessageRemoveTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMessageRemoveTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") chatMessageRemoveTextEmotionCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("emotion-id", "", "表情 ID (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("emotion-name", "", "表情名称 (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)") chatMessageRemoveTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)") cli.AnnotateRuntimeConstraints(chatMessageRemoveTextEmotionCmd, cli.RuntimeSchemaConstraints{ RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, }) chatMessageUpdateTextEmotionCmd := &cobra.Command{ Use: "update-text-emotion", Short: "更新消息的文字表情回应", Example: ` dws chat message update-text-emotion --conversation-id --message-id --old-emotion-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } if err := validateRequiredFlags(cmd, "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"); err != nil { return err } return callMCPToolOnServer("im", "update_text_emotion", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat", "open-conversation-id"), "openMsgId": flagOrFallback(cmd, "message-id", "msg-id"), "oldEmotionId": mustGetFlag(cmd, "old-emotion-id"), "emotionId": mustGetFlag(cmd, "emotion-id"), "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), "backgroundId": mustGetFlag(cmd, "background-id"), }) }, } DeclareLeafMetadata(chatMessageUpdateTextEmotionCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_text_emotion", CanonicalPath: "chat.update_text_emotion", CLIPath: "chat message update-text-emotion", PrimaryCLIPath: "chat message update-text-emotion", }, Description: "把消息上已有的文字表情原地替换为新的文字表情", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI calls im/update_text_emotion, which is proven by dws-wukong develop@30f13f02 but absent from the pinned MCP metadata snapshot; no pinned interface_ref can represent the command yet.", }, Selection: contract.SelectionSpec{ AgentSummary: "把消息上已有的文字表情原地替换为新的文字表情", UseWhen: []string{"需要更新消息的状态文字或表情,并避免先移除再添加造成闪烁和两次网络调用时"}, AvoidWhen: []string{ "消息上还没有文字表情时使用 chat message add-text-emotion", "只需清除文字表情时使用 chat message remove-text-emotion", }, Examples: []string{"dws chat message update-text-emotion --conversation-id --message-id --old-emotion-id --emotion-id --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"}, }, Parameters: []contract.ParamDecl{ {Name: "background-id", Property: "backgroundId", Required: boolPtr(true), Description: "新文字表情的背景 ID"}, {Name: "chat", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId"}, {Name: "emotion-id", Property: "emotionId", Required: boolPtr(true), Description: "新的文字表情 ID,可通过 create-text-emotion 获取"}, {Name: "emotion-name", Property: "emotionName", Required: boolPtr(true), Description: "新的文字表情名称"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, {Name: "id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, {Name: "message-id", Property: "openMsgId", Required: boolPtr(true), Description: "需要原地更新文字表情的消息 openMsgId"}, {Name: "old-emotion-id", Property: "oldEmotionId", Required: boolPtr(true), Description: "消息上当前文字表情的 emotionId"}, {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(false), Description: "会话 openConversationId 兼容入口"}, {Name: "text", Property: "text", Required: boolPtr(true), Description: "新的文字表情内容"}, }, }, }) chatMessageUpdateTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMessageUpdateTextEmotionCmd.Flags().String("open-conversation-id", "", "--conversation-id 的别名") chatMessageUpdateTextEmotionCmd.Flags().String("message-id", "", "消息 openMsgId (必填)") chatMessageUpdateTextEmotionCmd.Flags().String("old-emotion-id", "", "待更新的原表情 ID (必填)") chatMessageUpdateTextEmotionCmd.Flags().String("emotion-id", "", "新表情 ID (必填)") chatMessageUpdateTextEmotionCmd.Flags().String("emotion-name", "", "新表情名称 (必填)") chatMessageUpdateTextEmotionCmd.Flags().String("text", "", "新文字内容 (必填)") chatMessageUpdateTextEmotionCmd.Flags().String("background-id", "", "新背景 ID (必填)") _ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired("conversation-id") for _, name := range []string{"message-id", "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"} { _ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired(name) } cli.AnnotateRuntimeConstraints(chatMessageUpdateTextEmotionCmd, cli.RuntimeSchemaConstraints{ RequireOneOf: [][]string{{"conversation-id", "group", "id", "chat"}}, }) // ── 创建文字表情(获取 emotionId)────────────────────── chatMessageCreateTextEmotionCmd := &cobra.Command{ Use: "create-text-emotion", Short: "创建文字表情(获取 emotionId)", Long: `创建一个新的文字表情模板。当内置表情(见 chat-emoji-list.md)中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`, Example: ` dws chat message create-text-emotion --emotion-name "赞" --text "nice" dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "emotion-name", "text"); err != nil { return err } params := map[string]any{ "emotionName": mustGetFlag(cmd, "emotion-name"), "text": mustGetFlag(cmd, "text"), } if v, _ := cmd.Flags().GetString("background-id"); v != "" { params["backgroundId"] = v } return callMCPToolOnServer("im", "create_text_emotion", params) }, } DeclareLeafMetadata(chatMessageCreateTextEmotionCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_text_emotion", CanonicalPath: "chat.create_text_emotion", CLIPath: "chat message create-text-emotion", PrimaryCLIPath: "chat message create-text-emotion", }, Description: "创建可用于消息回应的文字表情", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_text_emotion"}, }, Selection: contract.SelectionSpec{ AgentSummary: "创建可用于消息回应的文字表情", UseWhen: []string{"需要定义新的文字表情资源时"}, AvoidWhen: []string{"已有 emotionId 并要回应消息时使用 chat message add-text-emotion"}, Examples: []string{"dws chat message create-text-emotion --emotion-name \"赞\" --text \"nice\" --background-id im_bg_5"}, }, }, }) chatMessageCreateTextEmotionCmd.Flags().String("emotion-name", "", "表情名称 (必填)") _ = chatMessageCreateTextEmotionCmd.MarkFlagRequired("emotion-name") chatMessageCreateTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)") _ = chatMessageCreateTextEmotionCmd.MarkFlagRequired("text") chatMessageCreateTextEmotionCmd.Flags().String("background-id", "", "背景 ID(可选,不传则由服务端默认分配)") // ── 流式卡片命令 ────────────────────────────────────────── chatMessageSendCardCmd := &cobra.Command{ Use: "send-card", Short: "创建并推送流式卡片", Long: `向群聊或单聊创建并推送流式卡片。群聊传 --conversation-id,单聊传 --open-dingtalk-id,二者互斥。 群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。 创建时无需传入卡片内容,后续通过 update-card 更新内容。 注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容, 最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。 flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`, Example: ` dws chat message send-card --conversation-id dws chat message send-card --conversation-id --at-open-dingtalk-ids dws chat message send-card --conversation-id --at-all dws chat message send-card --open-dingtalk-id # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") receiver := flagOrFallback(cmd, "open-dingtalk-id", "receiver") atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids"))) atAll, _ := cmd.Flags().GetBool("at-all") if groupID == "" && receiver == "" { return fmt.Errorf("--conversation-id or --open-dingtalk-id is required") } if groupID != "" && receiver != "" { return fmt.Errorf("--conversation-id and --open-dingtalk-id are mutually exclusive") } if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) { return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --conversation-id") } toolArgs := map[string]any{} if groupID != "" { toolArgs["openConversationId"] = groupID if len(atOpenDingTalkIDs) > 0 { toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs } if atAll { toolArgs["atAll"] = true } } if receiver != "" { resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver) if err != nil { return err } toolArgs["receiverOpenDingTalkId"] = resolved } return callMCPToolOnServer("im", "create_and_send_card", toolArgs) }, } DeclareLeafMetadata(chatMessageSendCardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_and_send_card", CanonicalPath: "chat.create_and_send_card", CLIPath: "chat message send-card", PrimaryCLIPath: "chat message send-card", }, Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"}, }, Selection: contract.SelectionSpec{ AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人", UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"}, AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"}, Examples: []string{"dws chat message send-card --group "}, }, Parameters: []contract.ParamDecl{ {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"}, {Name: "conversation-id", Property: "openConversationId"}, {Name: "group", Property: "openConversationId", Required: boolPtr(false)}, {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"}, {Name: "receiver", Property: "receiverOpenDingTalkId", Required: boolPtr(false)}, }, }, }) chatMessageSendCardCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填,与 --open-dingtalk-id 互斥)") chatMessageSendCardCmd.Flags().String("open-dingtalk-id", "", "单聊接收者 openDingTalkId(单聊时必填,与 --conversation-id 互斥)") chatMessageSendCardCmd.Flags().String("receiver", "", "--open-dingtalk-id 的兼容别名") _ = chatMessageSendCardCmd.Flags().MarkHidden("receiver") chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --conversation-id 一起使用)") chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --conversation-id 一起使用)") cli.AnnotateRuntimeConstraints(chatMessageSendCardCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "receiver"}}, RequireOneOf: [][]string{{"group", "receiver"}}, }) chatMessageUpdateCardCmd := &cobra.Command{ Use: "update-card", Short: "流式更新卡片内容", Long: `更新已发送的流式卡片内容。--biz-id 为 send-card 返回的业务 ID,--flow-status 控制流式状态。 flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。 最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。`, Example: ` dws chat message update-card --biz-id --content "更新的卡片内容" --flow-status 2 dws chat message update-card --biz-id --content "最终内容" --flow-status 3`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "biz-id", "content"); err != nil { return err } if !cmd.Flags().Changed("flow-status") { return fmt.Errorf("flag --flow-status is required") } bizID, err := chatmsg.NormalizeCardBizID(mustGetFlag(cmd, "biz-id")) if err != nil { return err } flowStatus, _ := cmd.Flags().GetInt("flow-status") if flowStatus < 1 || flowStatus > 5 { return fmt.Errorf("--flow-status 必须在 1-5 之间") } params := map[string]any{ "bizId": bizID, "msgContent": mustGetFlag(cmd, "content"), "flowStatus": flowStatus, } if commandDryRun(cmd) { return writeCommandPayload(cmd, map[string]any{ "dry_run": true, "executed": false, "verified": false, "action": map[string]any{ "product": "im", "tool": "update_streaming_card", "arguments": params, }, }) } text, err := CallMCPToolTextOnServer("im", "update_streaming_card", params) if err != nil { return err } var response map[string]any if strings.TrimSpace(text) == "" { response = map[string]any{} } else if err := unmarshalJSONUseNumber(text, &response); err != nil { return apperrors.NewInternal( fmt.Sprintf("解析 update_streaming_card 返回失败: %v", err), apperrors.WithReason("streaming_card_update_response_invalid"), ) } if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, response); err != nil { return nativeCardUpdateVerificationError(bizID, err) } return writeCommandPayload(cmd, response) }, } DeclareLeafMetadata(chatMessageUpdateCardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", // The typed command is the atomic MCP surface and intentionally // preserves its original no-extra-confirmation contract. The // Agent-facing +messages-update-card shortcut owns the higher-level // confirmation boundary. Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_streaming_card", CanonicalPath: "chat.update_streaming_card", CLIPath: "chat message update-card", PrimaryCLIPath: "chat message update-card", }, Description: "更新已发送流式卡片的内容和状态", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_streaming_card"}, }, Selection: contract.SelectionSpec{ AgentSummary: "更新已发送流式卡片的内容和状态", UseWhen: []string{"需要直接调用底层原子更新,并由调用方自行管理确认与更新节奏时"}, AvoidWhen: []string{"面向 Agent 的默认快速通道使用 chat +messages-update-card;创建新卡片时使用 chat message send-card"}, Examples: []string{"dws chat message update-card --biz-id --content \"处理完成\" --flow-status 2"}, }, Parameters: []contract.ParamDecl{ {Name: "biz-id", Property: "bizId"}, {Name: "content", Property: "msgContent"}, {Name: "flow-status", Property: "flowStatus"}, }, }, }) chatMessageUpdateCardCmd.Flags().String("biz-id", "", "卡片业务 ID (必填)") _ = chatMessageUpdateCardCmd.MarkFlagRequired("biz-id") chatMessageUpdateCardCmd.Flags().String("content", "", "卡片消息内容 (必填)") _ = chatMessageUpdateCardCmd.MarkFlagRequired("content") chatMessageUpdateCardCmd.Flags().Int("flow-status", 0, "流式状态 (必填)") // ── download-media:下载消息中的媒体资源(走 IM MCP)────── chatMessageDownloadMediaCmd := &cobra.Command{ Use: "download-media", Short: "下载消息中的资源(图片/视频/语音等)到本地", Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。 本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。 如需用 fileId 下载,请使用钉盘/drive 下载命令。 流程: 1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL 2. HTTP GET 下载文件到本地 --output 指定本地保存路径,可以是文件路径或目录。 如果指定目录,文件名从下载 URL 中自动推断。默认保存到当前目录。`, Example: ` dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./download.bin dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg # resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId # 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令 # message-id: 从 dws chat message list 返回的 openMessageId # open-conversation-id: 从 dws chat search 获取 openConversationId`, PreRunE: func(cmd *cobra.Command, args []string) error { // Cobra validates required flags after PreRunE. Copy a supplied alias // into the canonical flag first so --message-id can remain a hard // required fact in both the executable and Agent Schema contracts. if cmd.Flags().Changed("message-id") { return nil } alias := "" switch { case cmd.Flags().Changed("msg-id"): alias = "msg-id" case cmd.Flags().Changed("open-message-id"): alias = "open-message-id" default: return nil } value, _ := cmd.Flags().GetString(alias) // registered string flags above return cmd.Flags().Set("message-id", value) }, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil { return err } resourceType := mustGetFlag(cmd, "type") resourceID := mustGetFlag(cmd, "resource-id") conversationID := mustGetFlag(cmd, "open-conversation-id") messageID := mustGetFlag(cmd, "message-id") outputPath := mustGetFlag(cmd, "output") jsonMode := deps.Caller.Format() == "json" switch resourceType { case "mediaId": // current supported type default: return fmt.Errorf("unsupported resource type: %s (supported: mediaId)", resourceType) } if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "下载消息资源") deps.Out.PrintKeyValue("类型", resourceType) deps.Out.PrintKeyValue("资源ID", resourceID) deps.Out.PrintKeyValue("消息ID", messageID) deps.Out.PrintKeyValue("会话ID", conversationID) deps.Out.PrintKeyValue("输出", outputPath) return nil } ctx := context.Background() // Step 1: 获取下载 URL if !jsonMode { deps.Out.PrintInfo("[1/2] 获取资源下载链接...") } text, err := callMCPToolReturnTextOnServer(ctx, "im", "get_resource_download_url", map[string]any{ "resourceType": resourceType, "resourceId": resourceID, "openMessageId": messageID, "openConversationId": conversationID, }) if err != nil { return err } resourceURL, dlHeaders, err := parseDownloadInfo(text) if err != nil { return err } // 解析输出路径:目录(已存在的目录,或以分隔符结尾的意图目录)→ 追加推断文件名。 fi, statErr := os.Stat(outputPath) isDir := (statErr == nil && fi.IsDir()) || strings.HasSuffix(outputPath, string(os.PathSeparator)) || strings.HasSuffix(outputPath, "/") if isDir { filename := inferFilename(resourceURL) outputPath = filepath.Join(outputPath, filename) } // 确保目标父目录存在,否则 httpGetFile 打开文件会因目录缺失失败。 if dir := filepath.Dir(outputPath); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("创建输出目录失败: %w", err) } } // Step 2: HTTP GET 下载文件 if !jsonMode { deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath)) } if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { return err } if jsonMode { return deps.Out.PrintJSONUnescaped(map[string]any{ "success": true, "downloadUrl": resourceURL, "output": outputPath, }) } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, } DeclareLeafMetadata(chatMessageDownloadMediaCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "download_media", CanonicalPath: "chat.download_media", CLIPath: "chat message download-media", PrimaryCLIPath: "chat message download-media", }, Description: "下载消息中的媒体资源到本地", DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref", }, Selection: contract.SelectionSpec{ AgentSummary: "下载消息中的媒体资源到本地", UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"}, AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"}, Examples: []string{"dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ."}, }, }, }) // download-media flags chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("type") chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id") chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id") chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id") // Hidden aliases: agents routinely pass --msg-id / --open-message-id since // the message-list output exposes the field as openMessageId/msgId. Accept // them transparently instead of failing with "unknown flag". chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名") _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id") chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名") _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id") chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("output") chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageUpdateTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd) chatBotCmd.AddCommand(chatBotSearchCmd) chatCategoryCmd.AddCommand(chatCategoryListCmd, chatCategoryConvsCmd, chatCategoryCreateCmd, chatCategoryDeleteCmd, chatCategoryRenameCmd, chatCategoryAddConvCmd, chatCategoryRemoveConvCmd, chatCategoryListByConvCmd, chatCategoryBatchInfoCmd) chatGroupCmd.AddCommand(chatGroupInfoByIdCmd) // ── group 新增命令(群主转让、邀请链接、免打扰)────────── chatGroupTransferOwnerCmd := &cobra.Command{ Use: "transfer-owner", Short: "转让群主", Example: ` dws chat group transfer-owner --conversation-id --new-owner dws chat group transfer-owner --conversation-id --user # 查询群 ID: dws chat search --query "群名" # 查询 openDingTalkId: dws contact user search --query "姓名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } newOwnerOpenDingTalkID, _ := cmd.Flags().GetString("new-owner") newOwnerUserID := flagOrFallback(cmd, "user", "userId") if newOwnerOpenDingTalkID != "" && newOwnerUserID != "" { return fmt.Errorf("--new-owner and --user are mutually exclusive, specify exactly one") } newOwner := newOwnerOpenDingTalkID if newOwner == "" { newOwner = newOwnerUserID } if newOwner == "" { return fmt.Errorf("flag --new-owner or --user is required") } if !isOpenDingTalkID(newOwner) { return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "newOwnerUid": newOwner, }) } return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "newOwnerOpenDingTalkId": newOwner, }) }, } DeclareLeafMetadata(chatGroupTransferOwnerCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "transfer_group_owner", CanonicalPath: "chat.transfer_group_owner", CLIPath: "chat group transfer-owner", PrimaryCLIPath: "chat group transfer-owner", }, Description: "把群主身份转让给指定群成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "transfer_group_owner"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把群主身份转让给指定群成员", UseWhen: []string{"现群主明确指定新群主时"}, AvoidWhen: []string{"只是授予管理员权限时使用 chat group set-admin"}, Examples: []string{"dws chat group transfer-owner --conversation-id --new-owner "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "new-owner", Property: "newOwnerOpenDingTalkId"}, }, }, }) chatGroupTransferOwnerCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupTransferOwnerCmd.MarkFlagRequired("conversation-id") chatGroupTransferOwnerCmd.Flags().String("new-owner", "", "新群主 openDingTalkId") chatGroupTransferOwnerCmd.Flags().String("user", "", "新群主 userId") chatGroupTransferOwnerCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupTransferOwnerCmd.Flags().MarkHidden("userId") chatGroupInviteUrlCmd := &cobra.Command{ Use: "invite-url", Short: "获取群邀请链接", Long: `获取群聊邀请链接。可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。`, Example: ` dws chat group invite-url --conversation-id dws chat group invite-url --conversation-id --expires-seconds 86400 dws chat group invite-url --conversation-id --expires-seconds 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if v, _ := cmd.Flags().GetInt64("expires-seconds"); v >= 0 && cmd.Flags().Changed("expires-seconds") { toolArgs["expiresSeconds"] = v } return callMCPToolOnServer("im", "get_group_invite_url", toolArgs) }, } DeclareLeafMetadata(chatGroupInviteUrlCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_group_invite_url", CanonicalPath: "chat.get_group_invite_url", CLIPath: "chat group invite-url", PrimaryCLIPath: "chat group invite-url", }, Description: "获取指定群聊的邀请链接", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "get_group_invite_url"}, }, Selection: contract.SelectionSpec{ AgentSummary: "获取指定群聊的邀请链接", UseWhen: []string{"需要生成群邀请链接或设置有效期时"}, AvoidWhen: []string{"需要直接添加已知成员时使用 chat group members add"}, Examples: []string{"dws chat group invite-url --conversation-id --expires-seconds 86400"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupInviteUrlCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupInviteUrlCmd.MarkFlagRequired("conversation-id") chatGroupInviteUrlCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值") chatMuteCmd := &cobra.Command{ Use: "mute", Short: "会话消息免打扰", Long: `开启或关闭会话消息免打扰(支持单聊和群聊)。默认开启免打扰,传 --off 则关闭免打扰。`, Example: ` dws chat mute --conversation-id dws chat mute --conversation-id --off # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mute --conversation-id ") } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "update_notification_off", map[string]any{ "openConversationId": convID, "mute": !off, }) }, } DeclareLeafMetadata(chatMuteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_notification_off", CanonicalPath: "chat.update_notification_off", CLIPath: "chat mute", PrimaryCLIPath: "chat mute", }, Description: "开启或关闭指定会话的免打扰", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_notification_off"}, }, Selection: contract.SelectionSpec{ AgentSummary: "开启或关闭指定会话的免打扰", UseWhen: []string{"用户明确要静音或恢复某个会话通知时"}, AvoidWhen: []string{"群成员禁言属于发言权限,应使用 group-mute 命令"}, Examples: []string{"dws chat mute --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "off", Property: "mute", Required: boolPtr(false)}, }, }, }) chatMuteCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMuteCmd.Flags().String("id", "", "--conversation-id 的别名") chatMuteCmd.Flags().String("chat", "", "--conversation-id 的别名") chatMuteCmd.Flags().Bool("off", false, "关闭免打扰(不传则开启免打扰)") // ── group 新增命令(退群、更新群头像、更新群设置)────────── chatGroupQuitCmd := &cobra.Command{ Use: "quit", Short: "退出群聊", Long: `当前用户退出指定群聊。退出后将无法查看群消息。`, Example: ` dws chat group quit --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "quit_group", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), }) }, } DeclareLeafMetadata(chatGroupQuitCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "quit_group", CanonicalPath: "chat.quit_group", CLIPath: "chat group quit", PrimaryCLIPath: "chat group quit", }, Description: "当前用户退出群聊,群本身保留", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "quit_group"}, }, Selection: contract.SelectionSpec{ AgentSummary: "当前用户退出群聊,群本身保留", UseWhen: []string{"用户只要自己离开某个群,不需要解散群"}, AvoidWhen: []string{ "需要永久解散整个群时使用 chat group dismiss", "需要踢出其他成员时使用 chat group members remove", }, Examples: []string{"dws chat group quit --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupQuitCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupQuitCmd.MarkFlagRequired("conversation-id") chatGroupUpdateIconCmd := &cobra.Command{ Use: "update-icon", Short: "更新群头像", Long: `更新指定群聊的群头像。需传入群 ID 和头像 mediaId。`, Example: ` dws chat group update-icon --conversation-id --icon-media-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "icon-media-id"); err != nil { return err } iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id")) if iconMediaID == "" { return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 请使用上游媒体上传能力返回的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传命令") } return callMCPToolOnServer("im", "update_group_icon", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "iconMediaId": iconMediaID, }) }, } DeclareLeafMetadata(chatGroupUpdateIconCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_group_icon", CanonicalPath: "chat.update_group_icon", CLIPath: "chat group update-icon", PrimaryCLIPath: "chat group update-icon", }, Description: "使用真实媒体 ID 更新群头像", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_group_icon"}, }, Selection: contract.SelectionSpec{ AgentSummary: "使用真实媒体 ID 更新群头像", UseWhen: []string{"已有上传后的头像 mediaId 并要修改群头像时"}, AvoidWhen: []string{"没有真实可用 mediaId 时先完成媒体上传"}, Examples: []string{"dws chat group update-icon --conversation-id --icon-media-id @mediaId"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupUpdateIconCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupUpdateIconCmd.MarkFlagRequired("conversation-id") chatGroupUpdateIconCmd.Flags().String("icon-media-id", "", "群头像 mediaId (必填)") _ = chatGroupUpdateIconCmd.MarkFlagRequired("icon-media-id") chatGroupUpdateSettingsCmd := &cobra.Command{ Use: "update-settings", Short: "更新群设置", Long: `更新指定群聊的设置项。--setting-key 指定设置项,--status 指定值(0=关闭,1=开启)。 支持的 settingKey: authority 仅群主和管理员可管理 joinValidation 入群许可 onlyAdminCanAtAll 仅群主和管理员可@所有人 searchable 群可被搜索 addFriendForbidden 禁止群内私聊 toolbarStatus 群快捷栏状态 pluginCustomizeVerify 仅群主和管理员可管理快捷栏 onlyAdminCanDING 谁可以在群内发DING allMembersCanCreateMcsConf 谁可以在群里发起视频和语音会议 onlyAdminCanSetMsgTop 谁可以把群消息置顶 onlyAdminCanPinMsg 谁可以把群消息钉住 onlyAdminCanSendFile 谁可以上传文件、文件夹和钉盘文件 allMembersCanCreateCalendar 群成员日历可见性 groupEmailDisabled 群邮件组 groupRedEnvelopeSwitch 发红包 groupLiveAuthority 谁可以发起直播 groupBillAuthority 群收款开关`, Example: ` dws chat group update-settings --conversation-id --setting-key searchable --status 1 dws chat group update-settings --conversation-id --setting-key onlyAdminCanAtAll --status 0 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "setting-key"); err != nil { return err } if !cmd.Flags().Changed("status") { return fmt.Errorf("flag --status is required (0=关闭, 1=开启)") } status, _ := cmd.Flags().GetInt("status") return callMCPToolOnServer("im", "update_group_settings", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "settingKey": mustGetFlag(cmd, "setting-key"), "status": status, }) }, } DeclareLeafMetadata(chatGroupUpdateSettingsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_group_settings", CanonicalPath: "chat.update_group_settings", CLIPath: "chat group update-settings", PrimaryCLIPath: "chat group update-settings", }, Description: "更新指定群聊的一项设置开关", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_group_settings"}, }, Selection: contract.SelectionSpec{ AgentSummary: "更新指定群聊的一项设置开关", UseWhen: []string{"需要调整 searchable、入群验证或群权限等设置时"}, AvoidWhen: []string{"全员禁言和成员禁言使用专门的 mute 命令"}, Examples: []string{"dws chat group update-settings --conversation-id --setting-key searchable --status 1"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupUpdateSettingsCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupUpdateSettingsCmd.MarkFlagRequired("conversation-id") chatGroupUpdateSettingsCmd.Flags().String("setting-key", "", "群设置项 key (必填)") _ = chatGroupUpdateSettingsCmd.MarkFlagRequired("setting-key") chatGroupUpdateSettingsCmd.Flags().Int("status", 0, "设置值: 0=关闭, 1=开启 (必填)") chatGroupCmd.AddCommand(chatGroupTransferOwnerCmd, chatGroupInviteUrlCmd, chatGroupQuitCmd, chatGroupUpdateIconCmd, chatGroupUpdateSettingsCmd) // ── message reply: 引用回复消息 ────────────────────────── chatMessageReplyCmd := &cobra.Command{ Use: "reply", Short: "引用回复消息(支持单聊/群聊)", Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`, Example: ` dws chat message reply --group --ref-msg-id --ref-sender --content "收到,马上处理" dws chat message reply --group --ref-msg-id --ref-sender --content "请看一下" --at-open-dingtalk-ids `, PreRunE: func(cmd *cobra.Command, args []string) error { if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil { return err } return promoteLegacyChatString(cmd, "content", "text") }, RunE: func(cmd *cobra.Command, args []string) error { if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil { return err } if err := promoteLegacyChatString(cmd, "content", "text"); err != nil { return err } if err := validateRequiredFlags(cmd, "group", "ref-msg-id", "ref-sender", "content"); err != nil { return err } refSender := mustGetFlag(cmd, "ref-sender") if !isOpenDingTalkID(refSender) { resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender) if err != nil { return err } refSender = resolved } clawType := "" aiTag, _ := cmd.Flags().GetBool("ai-tag") if aiTag { clawType = edition.ClawType() } toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), "msgType": "reply", "clawType": clawType, } atAll, _ := cmd.Flags().GetBool("at-all") atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids") replyText := applyCurrentUserGroupMentions( toolArgs, mustGetFlag(cmd, "content"), atOpenIDs, atAll, ) replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs) replyContent := map[string]string{ "referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"), "srcMsgSendOpenDingTalkId": refSender, "replyMsgType": "text", "content": replyText, } contentJSON, _ := marshalJSONRaw(replyContent) toolArgs["content"] = string(contentJSON) if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v } return callMCPTool("send_personal_message", toolArgs) }, } DeclareLeafMetadata(chatMessageReplyCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message", CLIPath: "chat message reply", PrimaryCLIPath: "chat message reply", }, Description: "引用指定消息发送个人回复", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "引用指定消息发送个人回复", UseWhen: []string{"用户要针对某条已有消息进行引用回复时"}, AvoidWhen: []string{"无需引用上下文的普通消息使用 chat message send"}, Examples: []string{"dws chat message reply --group --ref-msg-id --ref-sender --content \"收到\""}, }, Parameters: []contract.ParamDecl{ {Name: "ai-tag", Property: "clawType", InterfaceType: "string"}, {Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"}, {Name: "group", Property: "openConversationId"}, }, }, }) corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{ Name: "group", Usage: "会话 openConversationId (必填,支持单聊/群聊)", Aliases: []string{"conversation-id"}, }}) _ = chatMessageReplyCmd.MarkFlagRequired("group") chatMessageReplyCmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)") _ = chatMessageReplyCmd.MarkFlagRequired("ref-msg-id") chatMessageReplyCmd.Flags().String("ref-sender", "", "被引用消息的发送者 openDingTalkId (必填)") _ = chatMessageReplyCmd.MarkFlagRequired("ref-sender") corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{ Name: "content", Usage: "回复内容 (必填)", Aliases: []string{"text"}, }}) _ = chatMessageReplyCmd.MarkFlagRequired("content") chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)") chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)") chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)") chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)") cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat") // ── message forward: 转发单条消息 ──────────────────────── chatMessageForwardCmd := &cobra.Command{ Use: "forward", Short: "转发单条消息(源/目标会话均支持单聊/群聊)", Long: `将一条消息从源会话转发到目标会话。需要指定源会话 ID、源消息 ID、目标会话 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (openDingTalkId 可通过 dws contact user search --query "姓名" 获取)`, Example: ` dws chat message forward --src-conversation-id --message-id --dest-conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "src-conversation-id", "dest-conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "message-id", "msg-id"); err != nil { return err } toolArgs := map[string]any{ "srcOpenCid": mustGetFlag(cmd, "src-conversation-id"), "srcOpenMessageId": flagOrFallback(cmd, "message-id", "msg-id"), "destOpenCid": mustGetFlag(cmd, "dest-conversation-id"), } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v } return callMCPToolOnServer("im", "forward_message", toolArgs) }, } DeclareLeafMetadata(chatMessageForwardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "forward_message", CanonicalPath: "chat.forward_message", CLIPath: "chat message forward", PrimaryCLIPath: "chat message forward", }, Description: "把一条已有消息转发到另一个会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "forward_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把一条已有消息转发到另一个会话", UseWhen: []string{"已知源消息与源、目标会话 ID 时"}, AvoidWhen: []string{"合并转发多条消息时使用 chat message combine-forward"}, Examples: []string{"dws chat message forward --src-conversation-id --message-id --dest-conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "dest-conversation-id", Property: "destOpenCid"}, {Name: "message-id", Property: "srcOpenMessageId"}, {Name: "src-conversation-id", Property: "srcOpenCid"}, }, }, }) chatMessageForwardCmd.Flags().String("src-conversation-id", "", "源会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageForwardCmd.MarkFlagRequired("src-conversation-id") chatMessageForwardCmd.Flags().String("message-id", "", "源消息 openMessageId (必填)") _ = chatMessageForwardCmd.MarkFlagRequired("message-id") chatMessageForwardCmd.Flags().String("dest-conversation-id", "", "目标会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageForwardCmd.MarkFlagRequired("dest-conversation-id") chatMessageForwardCmd.Flags().String("uuid", "", "幂等键(可选)") // ── set-top: 会话置顶 ────────────────────────────────── chatSetTopCmd := &cobra.Command{ Use: "set-top", Short: "会话置顶 / 取消置顶(支持单聊/群聊)", Long: `设置或取消会话置顶。默认设置置顶,传 --off 则取消置顶。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (openDingTalkId 可通过 dws contact user search --query "姓名" 获取)`, Example: ` dws chat set-top --conversation-id dws chat set-top --conversation-id --off`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "set_top_conversation", map[string]any{ "openConversationId": mustGetFlag(cmd, "conversation-id"), "top": !off, }) }, } DeclareLeafMetadata(chatSetTopCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_top_conversation", CanonicalPath: "chat.set_top_conversation", CLIPath: "chat set-top", PrimaryCLIPath: "chat set-top", }, Description: "设置或取消指定会话置顶", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_top_conversation"}, }, Selection: contract.SelectionSpec{ AgentSummary: "设置或取消指定会话置顶", UseWhen: []string{"需要改变某个会话在列表中的置顶状态时"}, AvoidWhen: []string{"只查看置顶清单时使用 chat list-top-conversations"}, Examples: []string{"dws chat set-top --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "off", Property: "top", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatSetTopCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") _ = chatSetTopCmd.MarkFlagRequired("conversation-id") chatSetTopCmd.Flags().Bool("off", false, "取消置顶(不传则设置置顶)") // ── group get-mute-config: 查询群用户禁言配置 ─────────────── chatGroupGetMuteConfigCmd := &cobra.Command{ Use: "get-mute-config", Short: "查询群用户禁言配置", Long: `查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。 返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。`, Example: ` dws chat group get-mute-config --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group"); err != nil { return err } return callMCPToolOnServer("im", "get_group_mute_config", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group"), }) }, } DeclareLeafMetadata(chatGroupGetMuteConfigCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_group_mute_config", CanonicalPath: "chat.get_group_mute_config", CLIPath: "chat group get-mute-config", PrimaryCLIPath: "chat group get-mute-config", }, Description: "查询群用户禁言配置(禁言黑名单/全员禁言白名单)", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "查询群用户禁言配置(禁言黑名单/全员禁言白名单)", UseWhen: []string{"用户说 看下群里谁被禁言/禁言配置"}, AvoidWhen: []string{"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"}, Examples: []string{"dws chat group get-mute-config --conversation-id --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "group", Required: boolPtr(true)}, }, }, }) chatGroupGetMuteConfigCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") chatGroupGetMuteConfigCmd.Flags().String("group", "", "--conversation-id 的别名") chatGroupCmd.AddCommand(chatGroupGetMuteConfigCmd) // ── group-mute: 全员禁言 ─────────────────────────────── chatGroupMuteCmd := &cobra.Command{ Use: "group-mute", Short: "全员禁言 / 取消全员禁言", Long: `设置或取消群全员禁言。默认开启全员禁言,传 --off 则取消。`, Example: ` dws chat group-mute --conversation-id dws chat group-mute --conversation-id --off # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if groupID == "" { return fmt.Errorf("flag --group is required\n hint: dws chat group-mute --conversation-id ") } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "set_group_mute", map[string]any{ "openConversationId": groupID, "mute": !off, }) }, } DeclareLeafMetadata(chatGroupMuteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_group_mute", CanonicalPath: "chat.set_group_mute", CLIPath: "chat group-mute", PrimaryCLIPath: "chat group-mute", }, Description: "开启或关闭群聊全员禁言", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_group_mute"}, }, Selection: contract.SelectionSpec{ AgentSummary: "开启或关闭群聊全员禁言", UseWhen: []string{"需要控制整个群的发言权限时"}, AvoidWhen: []string{"只禁言指定成员时使用 chat group-mute-member"}, Examples: []string{"dws chat group-mute --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "mute", Required: boolPtr(false)}, }, }, }) chatGroupMuteCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") chatGroupMuteCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatGroupMuteCmd.Flags().MarkHidden("group") chatGroupMuteCmd.Flags().String("id", "", "--group 的别名") _ = chatGroupMuteCmd.Flags().MarkHidden("id") chatGroupMuteCmd.Flags().String("chat", "", "--group 的别名") _ = chatGroupMuteCmd.Flags().MarkHidden("chat") chatGroupMuteCmd.Flags().Bool("off", false, "取消全员禁言(不传则开启禁言)") // ── group-mute-member: 指定群成员禁言 ────────────────── chatGroupMuteMemberCmd := &cobra.Command{ Use: "group-mute-member", Short: "指定群成员禁言 / 取消禁言", Long: `将指定群成员加入或移出禁言名单。 --mute-time 禁言时长(毫秒),仅支持 5min(300000) / 1h(3600000) / 1d(86400000) / 7d(604800000) / 30d(2592000000)。 默认加入禁言名单,传 --off 则移除。`, Example: ` dws chat group-mute-member --conversation-id --users userId1,userId2 --mute-time 3600000 dws chat group-mute-member --conversation-id --user userId1 --mute-time 3600000 dws chat group-mute-member --conversation-id --user userId1,userId2 --off # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id", "chat") if groupID == "" { return fmt.Errorf("flag --group is required\n hint: dws chat group-mute-member --conversation-id --user --mute-time ") } usersRaw := flagOrFallback(cmd, "users", "user", "userId") if usersRaw == "" { return fmt.Errorf("flag --users or --user is required") } userIDs, openDingTalkIDs := splitChatIDValues(parseCSVValues(usersRaw)) // 服务端 set_group_member_mute_list 的 uids(staffId)入参存在缺陷: // 即使传了 uids 仍返回 "uids is required",而 openDingTalkIds 路径正常。 // 与 message send 一致:先把 userId 解析为 openDingTalkId;解析失败再降级透传 uids。 // Resolving userId to openDingTalkId is a remote preflight. A dry-run // must preserve the supplied uids in its preview without calling MCP. if len(userIDs) > 0 && !deps.Caller.DryRun() { if resolved, err := resolveOpenDingTalkIDs(cmd.Context(), userIDs); err == nil { openDingTalkIDs = append(openDingTalkIDs, resolved...) userIDs = nil } } off, _ := cmd.Flags().GetBool("off") toolArgs := map[string]any{ "openConversationId": groupID, "mute": !off, } if len(userIDs) > 0 { toolArgs["uids"] = userIDs } if len(openDingTalkIDs) > 0 { toolArgs["openDingTalkIds"] = openDingTalkIDs } if !off { muteTime, _ := cmd.Flags().GetInt64("mute-time") if muteTime <= 0 { return fmt.Errorf("--mute-time is required when muting (supported: 300000/3600000/86400000/604800000/2592000000)") } toolArgs["muteTime"] = muteTime } return callMCPToolOnServer("im", "set_group_member_mute_list", toolArgs) }, } DeclareLeafMetadata(chatGroupMuteMemberCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_group_member_mute_list", CanonicalPath: "chat.set_group_member_mute_list", CLIPath: "chat group-mute-member", PrimaryCLIPath: "chat group-mute-member", }, Description: "禁言或解除禁言指定群成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_group_member_mute_list"}, }, Selection: contract.SelectionSpec{ AgentSummary: "禁言或解除禁言指定群成员", UseWhen: []string{"需要按成员设置禁言时长或解除禁言时"}, AvoidWhen: []string{"需要全员禁言时使用 chat group-mute"}, Examples: []string{"dws chat group-mute-member --conversation-id --users userId1,userId2 --mute-time 3600000"}, }, Parameters: []contract.ParamDecl{ {Name: "group", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "mute", Required: boolPtr(false)}, {Name: "users", Property: "openDingTalkIds"}, }, }, }) chatGroupMuteMemberCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") chatGroupMuteMemberCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatGroupMuteMemberCmd.Flags().MarkHidden("group") chatGroupMuteMemberCmd.Flags().String("id", "", "--group 的别名") _ = chatGroupMuteMemberCmd.Flags().MarkHidden("id") chatGroupMuteMemberCmd.Flags().String("chat", "", "--group 的别名") _ = chatGroupMuteMemberCmd.Flags().MarkHidden("chat") chatGroupMuteMemberCmd.Flags().String("users", "", "群成员 userId 列表,逗号分隔(批量)") chatGroupMuteMemberCmd.Flags().String("user", "", "群成员 userId,支持逗号分隔") chatGroupMuteMemberCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupMuteMemberCmd.Flags().MarkHidden("userId") chatGroupMuteMemberCmd.Flags().Int64("mute-time", 0, "禁言时长(毫秒),支持 300000/3600000/86400000/604800000/2592000000") chatGroupMuteMemberCmd.Flags().Bool("off", false, "移出禁言名单(不传则加入禁言名单)") // ── group set-admin: 设置群成员为管理员 ───────────────── chatGroupSetAdminCmd := &cobra.Command{ Use: "set-admin", Short: "设置 / 取消群管理员", Long: `将指定群成员设置为管理员或取消管理员身份。默认设为管理员,传 --off 则取消。`, Example: ` dws chat group set-admin --conversation-id --users userId1,userId2 dws chat group set-admin --conversation-id --user userId1 dws chat group set-admin --conversation-id --user userId1 --off # 查询群 ID: dws chat search --query "群名" # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } usersRaw := flagOrFallback(cmd, "users", "user", "userId") if usersRaw == "" { return fmt.Errorf("flag --users or --user is required") } userIDs, openDingTalkIDs := splitChatIDValues(parseCSVValues(usersRaw)) off, _ := cmd.Flags().GetBool("off") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "admin": !off, } if len(userIDs) > 0 { toolArgs["uids"] = userIDs } if len(openDingTalkIDs) > 0 { toolArgs["openDingTalkIds"] = openDingTalkIDs } return callMCPToolOnServer("im", "update_conv_member_roles", toolArgs) }, } DeclareLeafMetadata(chatGroupSetAdminCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_conv_member_roles", CanonicalPath: "chat.update_conv_member_roles", CLIPath: "chat group set-admin", PrimaryCLIPath: "chat group set-admin", }, Description: "设置或取消群管理员角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_conv_member_roles"}, }, Selection: contract.SelectionSpec{ AgentSummary: "设置或取消群管理员角色", UseWhen: []string{"需要变更指定成员的群管理员身份时"}, AvoidWhen: []string{"自定义业务角色应使用 chat group-role 系列命令"}, Examples: []string{"dws chat group set-admin --conversation-id --users userId1,userId2"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "off", Property: "admin", Required: boolPtr(false)}, {Name: "users", Property: "openDingTalkIds"}, }, }, }) chatGroupSetAdminCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupSetAdminCmd.MarkFlagRequired("conversation-id") chatGroupSetAdminCmd.Flags().String("users", "", "成员 userId 列表,逗号分隔(批量)") chatGroupSetAdminCmd.Flags().String("user", "", "成员 userId,支持逗号分隔") chatGroupSetAdminCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupSetAdminCmd.Flags().MarkHidden("userId") chatGroupSetAdminCmd.Flags().Bool("off", false, "取消管理员(不传则设为管理员)") chatGroupCmd.AddCommand(chatGroupSetAdminCmd) chatMessageCmd.AddCommand(chatMessageReplyCmd, chatMessageForwardCmd) // info-by-id flags chatGroupInfoByIdCmd.Flags().Int64("group-id", 0, "群号 (必填,数字类型)") _ = chatGroupInfoByIdCmd.MarkFlagRequired("group-id") // category conversations flags chatCategoryConvsCmd.Flags().Int("category-id", 0, "会话分组 ID (必填)") chatCategoryConvsCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的会话(默认 false)") _ = chatCategoryConvsCmd.MarkFlagRequired("category-id") // category create flags chatCategoryCreateCmd.Flags().String("title", "", "分组名称,最多 15 个字符 (必填)") _ = chatCategoryCreateCmd.MarkFlagRequired("title") // category delete flags chatCategoryDeleteCmd.Flags().Int64("category-id", 0, "会话分组 ID (必填)") // category rename flags chatCategoryRenameCmd.Flags().Int64("category-id", 0, "会话分组 ID (必填)") chatCategoryRenameCmd.Flags().String("title", "", "新的分组名称,最多 15 个字符 (必填)") _ = chatCategoryRenameCmd.MarkFlagRequired("title") // category add-conv flags chatCategoryAddConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") chatCategoryAddConvCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatCategoryAddConvCmd.Flags().MarkHidden("group") chatCategoryAddConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryAddConvCmd.Flags().MarkHidden("id") chatCategoryAddConvCmd.Flags().String("category-ids", "", "目标分组 ID 列表,逗号分隔 (必填)") _ = chatCategoryAddConvCmd.MarkFlagRequired("category-ids") // category remove-conv flags chatCategoryRemoveConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") chatCategoryRemoveConvCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatCategoryRemoveConvCmd.Flags().MarkHidden("group") chatCategoryRemoveConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryRemoveConvCmd.Flags().MarkHidden("id") chatCategoryRemoveConvCmd.Flags().String("category-ids", "", "目标分组 ID 列表,逗号分隔 (必填)") _ = chatCategoryRemoveConvCmd.MarkFlagRequired("category-ids") // category list-by-conv flags chatCategoryListByConvCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填)") _ = chatCategoryListByConvCmd.MarkFlagRequired("conversation-id") chatCategoryListByConvCmd.Flags().String("group", "", "--conversation-id 的别名") _ = chatCategoryListByConvCmd.Flags().MarkHidden("group") chatCategoryListByConvCmd.Flags().String("id", "", "--group 的别名") _ = chatCategoryListByConvCmd.Flags().MarkHidden("id") cli.AnnotateRuntimeRequiredFlags(chatCategoryListByConvCmd, "conversation-id") // category batch-info flags chatCategoryBatchInfoCmd.Flags().String("category-ids", "", "分组 ID 列表,逗号分隔 (必填)") _ = chatCategoryBatchInfoCmd.MarkFlagRequired("category-ids") // ── group-role 子命令(群身份管理)──────────────────────── chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE} chatGroupRoleListCmd := &cobra.Command{ Use: "list", Short: "拉取会话的群身份列表", Example: ` dws chat group-role list --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { groupID := flagOrFallback(cmd, "conversation-id", "group", "id") if groupID == "" { return fmt.Errorf("flag --group is required\n hint: dws chat group-role list --conversation-id ") } return callMCPToolOnServer("im", "list_custom_group_roles", map[string]any{ "openConversationId": groupID, }) }, } DeclareLeafMetadata(chatGroupRoleListCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_custom_group_roles", CanonicalPath: "chat.list_custom_group_roles", CLIPath: "chat group-role list", PrimaryCLIPath: "chat group-role list", }, Description: "列出群聊中的自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_custom_group_roles"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出群聊中的自定义角色", UseWhen: []string{"需要取得角色 ID 或查看角色定义时"}, AvoidWhen: []string{"查询某个成员已分配角色时使用 chat group-role query-user"}, Examples: []string{"dws chat group-role list --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupRoleListCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleListCmd.MarkFlagRequired("conversation-id") chatGroupRoleAddCmd := &cobra.Command{ Use: "add", Short: "添加群身份", Example: ` dws chat group-role add --conversation-id --name "管理员"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "name"); err != nil { return err } return callMCPToolOnServer("im", "add_custom_group_role", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "name": mustGetFlag(cmd, "name"), }) }, } DeclareLeafMetadata(chatGroupRoleAddCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_custom_group_role", CanonicalPath: "chat.add_custom_group_role", CLIPath: "chat group-role add", PrimaryCLIPath: "chat group-role add", }, Description: "在群聊中创建自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_custom_group_role"}, }, Selection: contract.SelectionSpec{ AgentSummary: "在群聊中创建自定义角色", UseWhen: []string{"需要新增可分配给群成员的业务角色时"}, AvoidWhen: []string{"设置系统管理员角色时使用 chat group set-admin"}, Examples: []string{"dws chat group-role add --conversation-id --name \"值班负责人\""}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupRoleAddCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleAddCmd.MarkFlagRequired("conversation-id") chatGroupRoleAddCmd.Flags().String("name", "", "群身份名称 (必填)") _ = chatGroupRoleAddCmd.MarkFlagRequired("name") chatGroupRoleUpdateCmd := &cobra.Command{ Use: "update", Short: "更新群身份名称", Example: ` dws chat group-role update --conversation-id --role-id --name "新名称"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "role-id", "name"); err != nil { return err } return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleId": mustGetFlag(cmd, "role-id"), "name": mustGetFlag(cmd, "name"), }) }, } DeclareLeafMetadata(chatGroupRoleUpdateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_custom_group_role", CanonicalPath: "chat.update_custom_group_role", CLIPath: "chat group-role update", PrimaryCLIPath: "chat group-role update", }, Description: "更新群聊自定义角色的名称", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_group_role"}, }, Selection: contract.SelectionSpec{ AgentSummary: "更新群聊自定义角色的名称", UseWhen: []string{"已知角色 ID 并需要重命名该角色时"}, AvoidWhen: []string{"需要变更成员角色分配时使用 set-user 或 remove-user"}, Examples: []string{"dws chat group-role update --conversation-id --role-id --name \"新名称\""}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-id", Property: "openRoleId"}, }, }, }) chatGroupRoleUpdateCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleUpdateCmd.MarkFlagRequired("conversation-id") chatGroupRoleUpdateCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") _ = chatGroupRoleUpdateCmd.MarkFlagRequired("role-id") chatGroupRoleUpdateCmd.Flags().String("name", "", "群身份新名称 (必填)") _ = chatGroupRoleUpdateCmd.MarkFlagRequired("name") chatGroupRoleRemoveCmd := &cobra.Command{ Use: "remove", Short: "删除群身份", Example: ` dws chat group-role remove --conversation-id --role-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "role-id"); err != nil { return err } return callMCPToolOnServer("im", "remove_custom_group_role", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleId": mustGetFlag(cmd, "role-id"), }) }, } DeclareLeafMetadata(chatGroupRoleRemoveCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_custom_group_role", CanonicalPath: "chat.remove_custom_group_role", CLIPath: "chat group-role remove", PrimaryCLIPath: "chat group-role remove", }, Description: "删除群聊中的自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_group_role"}, }, Selection: contract.SelectionSpec{ AgentSummary: "删除群聊中的自定义角色", UseWhen: []string{"明确要移除整个自定义角色定义时"}, AvoidWhen: []string{"只取消某个成员的角色时使用 chat group-role remove-user"}, Examples: []string{"dws chat group-role remove --conversation-id --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-id", Property: "openRoleId"}, }, }, }) chatGroupRoleRemoveCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleRemoveCmd.MarkFlagRequired("conversation-id") chatGroupRoleRemoveCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") _ = chatGroupRoleRemoveCmd.MarkFlagRequired("role-id") chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, PreRunE: func(cmd *cobra.Command, args []string) error { return prepareChatGroupRoleSetUserRoleID(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd) if err != nil { return err } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleIds": roleIDs, } if isOpenDingTalkID(user) { toolArgs["openDingTalkId"] = user } else { toolArgs["userId"] = user } return callMCPToolOnServer("im", "set_custom_user_roles", toolArgs) }, } DeclareLeafMetadata(chatGroupRoleSetUserCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_custom_user_roles", CanonicalPath: "chat.set_custom_user_roles", CLIPath: "chat group-role set-user", PrimaryCLIPath: "chat group-role set-user", }, Description: "为指定群成员设置自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_custom_user_roles"}, }, Selection: contract.SelectionSpec{ AgentSummary: "为指定群成员设置自定义角色", UseWhen: []string{"需要把一个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, Examples: []string{"dws chat group-role set-user --conversation-id --user --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-id", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) chatGroupRoleSetUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleSetUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", Short: "移除用户的指定群身份", Example: ` dws chat group-role remove-user --conversation-id --user --role-ids roleId1,roleId2`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "openRoleIds": roleIDs, } if isOpenDingTalkID(user) { toolArgs["openDingTalkId"] = user } else { toolArgs["userId"] = user } return callMCPToolOnServer("im", "remove_custom_user_roles", toolArgs) }, } DeclareLeafMetadata(chatGroupRoleRemoveUserCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_custom_user_roles", CanonicalPath: "chat.remove_custom_user_roles", CLIPath: "chat group-role remove-user", PrimaryCLIPath: "chat group-role remove-user", }, Description: "取消指定成员的一个或多个自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_user_roles"}, }, Selection: contract.SelectionSpec{ AgentSummary: "取消指定成员的一个或多个自定义角色", UseWhen: []string{"需要保留角色定义但解除成员角色时"}, AvoidWhen: []string{"删除角色定义本身时使用 chat group-role remove"}, Examples: []string{"dws chat group-role remove-user --conversation-id --user --role-ids roleId1,roleId2"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "role-ids", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) chatGroupRoleRemoveUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleRemoveUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleRemoveUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleRemoveUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleRemoveUserCmd.Flags().MarkHidden("userId") chatGroupRoleRemoveUserCmd.Flags().String("role-ids", "", "要移除的群身份 openRoleId 列表,逗号分隔 (必填)") _ = chatGroupRoleRemoveUserCmd.MarkFlagRequired("role-ids") chatGroupRoleQueryUserCmd := &cobra.Command{ Use: "query-user", Short: "查询群成员的群身份", Example: ` dws chat group-role query-user --conversation-id --user `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if isOpenDingTalkID(user) { toolArgs["openDingTalkId"] = user } else { toolArgs["userId"] = user } return callMCPToolOnServer("im", "query_custom_user_roles", toolArgs) }, } DeclareLeafMetadata(chatGroupRoleQueryUserCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "query_custom_user_roles", CanonicalPath: "chat.query_custom_user_roles", CLIPath: "chat group-role query-user", PrimaryCLIPath: "chat group-role query-user", }, Description: "查询指定群成员的自定义角色", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "query_custom_user_roles"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定群成员的自定义角色", UseWhen: []string{"需要核对某个成员在群内的业务角色时"}, AvoidWhen: []string{"列出全部角色定义时使用 chat group-role list"}, Examples: []string{"dws chat group-role query-user --conversation-id --user "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, {Name: "user", Property: "openDingTalkId"}, }, }, }) chatGroupRoleQueryUserCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupRoleQueryUserCmd.MarkFlagRequired("conversation-id") chatGroupRoleQueryUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleQueryUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleQueryUserCmd.Flags().MarkHidden("userId") chatGroupRoleCmd.AddCommand( chatGroupRoleListCmd, chatGroupRoleAddCmd, chatGroupRoleUpdateCmd, chatGroupRoleRemoveCmd, chatGroupRoleSetUserCmd, chatGroupRoleRemoveUserCmd, chatGroupRoleQueryUserCmd, ) // ── 群机器人 / 群解散 / 历史消息 / 合并转发(5.18 + 5.14 表)── chatGroupBotsCmd := &cobra.Command{ Use: "bots", Short: "查看群内所有机器人", Long: `获取指定群聊中的所有机器人列表。`, Example: ` dws chat group bots --group # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "group"); err != nil { return err } groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group")) if err != nil { return err } return callMCPToolOnServer("bot", "list_group_bots", map[string]any{ "openConversationId": groupID, }) }, } DeclareLeafMetadata(chatGroupBotsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_group_bots", CanonicalPath: "chat.list_group_bots", CLIPath: "chat group bots", PrimaryCLIPath: "chat group bots", }, Description: "列出群内机器人", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "list_group_bots"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出群内机器人", UseWhen: []string{"需要查看某群已安装哪些机器人或提取 openBotId"}, AvoidWhen: []string{"搜索企业内机器人目录时使用 chat bot find"}, Examples: []string{"dws chat group bots --group "}, }, Parameters: []contract.ParamDecl{ {Name: "group", Property: "openConversationId"}, }, }, }) chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)") _ = chatGroupBotsCmd.MarkFlagRequired("group") chatGroupMembersRemoveBotCmd := &cobra.Command{ Use: "remove-bot", Short: "从群内移除机器人", Long: `将指定机器人从群聊中移除。需要群管理员或群主权限。`, Example: ` dws chat group members remove-bot --id --bot-id # 查询群 ID: dws chat search --query "群名" # 查询群内机器人: dws chat group bots --group `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id", "bot-id"); err != nil { return err } return callMCPToolOnServer("bot", "remove_robot_in_group", map[string]any{ "openConversationId": mustGetFlag(cmd, "id"), "openBotId": mustGetFlag(cmd, "bot-id"), }) }, } DeclareLeafMetadata(chatGroupMembersRemoveBotCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_robot_in_group", CanonicalPath: "chat.remove_robot_in_group", CLIPath: "chat group members remove-bot", PrimaryCLIPath: "chat group members remove-bot", }, Description: "从群内移除指定机器人", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "remove_robot_in_group"}, }, Selection: contract.SelectionSpec{ AgentSummary: "从群内移除指定机器人", UseWhen: []string{"需要把某个机器人踢出指定群"}, AvoidWhen: []string{ "移除普通成员时使用 chat group members remove", "解散整个群时使用 chat group dismiss", }, Examples: []string{"dws chat group members remove-bot --id --bot-id "}, }, Parameters: []contract.ParamDecl{ {Name: "bot-id", Property: "openBotId"}, {Name: "id", Property: "openConversationId"}, }, }, }) chatGroupMembersRemoveBotCmd.Flags().String("id", "", "群聊 openConversationId (必填)") _ = chatGroupMembersRemoveBotCmd.MarkFlagRequired("id") chatGroupMembersRemoveBotCmd.Flags().String("bot-id", "", "机器人 openBotId (必填)") _ = chatGroupMembersRemoveBotCmd.MarkFlagRequired("bot-id") chatBotFindCmd := &cobra.Command{ Use: "find", Short: "搜索【全部可用】机器人(含他人/官方,额外返回 openDingTalkId 可发单聊)", Long: `按关键词搜索当前用户可用的【全部】机器人(含他人创建、官方机器人),支持游标分页。 如何在 find 与 search 之间选择: - find :用户说"搜索机器人""找一个机器人""所有可用机器人""帮我找 XXX 机器人" (不限范围,包含他人/官方)→ 用 find - search:用户说"我创建的""我的""我自己的""我做的"机器人 → 用 search(dws chat bot search) 返回字段差异(核心区分点): - find 额外返回 openDingTalkId(可用于给机器人发单聊消息) - search 没有 openDingTalkId 如果后续需要给机器人发单聊消息,必须用 find 拿 openDingTalkId。`, Example: ` dws chat bot find --query "日报" dws chat bot find --query "日报" --limit 20 # 拿到 openDingTalkId 后可用于给机器人发单聊消息`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { return err } toolArgs := map[string]any{ "keyword": flagOrFallback(cmd, "query", "keyword"), } if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } return callMCPToolOnServer("bot", "search_bots", toolArgs) }, } DeclareLeafMetadata(chatBotFindCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "search_bots", CanonicalPath: "chat.search_bots", CLIPath: "chat bot find", PrimaryCLIPath: "chat bot find", }, Description: "按关键词搜索企业机器人并拿到 openDingTalkId", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "bot", RPCName: "search_bots"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按关键词搜索企业机器人并拿到 openDingTalkId", UseWhen: []string{"要找可用机器人并提取 openDingTalkId(例如后续单聊机器人)"}, AvoidWhen: []string{ "只查我创建的机器人时使用 chat bot search", "已有 robot-code 直接发消息时不要先搜", }, Examples: []string{"dws chat bot find --query \"日报\""}, }, Parameters: []contract.ParamDecl{ {Name: "query", Property: "keyword"}, }, }, }) chatBotFindCmd.Flags().String("query", "", "搜索关键词 (必填)") chatBotFindCmd.Flags().String("keyword", "", "--query 的别名") _ = chatBotFindCmd.Flags().MarkHidden("keyword") chatBotFindCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") chatBotFindCmd.Flags().String("cursor", "", "分页游标(首次调用不传,翻页时传上次返回的 nextCursor)") chatGroupDismissCmd := &cobra.Command{ Use: "dismiss", Short: "解散群聊", Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`, Example: ` dws chat group dismiss --conversation-id # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "dismiss_group", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), }) }, } DeclareLeafMetadata(chatGroupDismissCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "dismiss_group", CanonicalPath: "chat.dismiss_group", CLIPath: "chat group dismiss", PrimaryCLIPath: "chat group dismiss", }, Description: "永久解散指定群聊(不可恢复,仅群主)", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "dismiss_group"}, }, Selection: contract.SelectionSpec{ AgentSummary: "永久解散指定群聊(不可恢复,仅群主)", UseWhen: []string{ "群主明确要求永久解散整个群,而不是自己退出", "已确认 openConversationId,且用户接受群与历史消息一并消失", }, AvoidWhen: []string{ "当前用户只想自己离开群时使用 chat group quit", "仍需保留群供他人继续使用时不要解散", "目标群未确认或用户未明确同意不可恢复后果时不要执行", }, Examples: []string{"dws chat group dismiss --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupDismissCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupDismissCmd.MarkFlagRequired("conversation-id") chatGroupSetHistoryCmd := &cobra.Command{ Use: "set-history", Short: "设置新成员入群可查看历史消息选项", Long: `设置新成员入群后可查看的历史消息范围。--option 取值: FORBIDDEN 禁止查看历史消息 RECENT_100 可查看最近 100 条消息 ALL 可查看全部历史消息`, Example: ` dws chat group set-history --conversation-id --option RECENT_100 dws chat group set-history --conversation-id --option FORBIDDEN # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "option"); err != nil { return err } option := mustGetFlag(cmd, "option") switch option { case "FORBIDDEN", "RECENT_100", "ALL": default: return fmt.Errorf("--option must be one of FORBIDDEN | RECENT_100 | ALL, got %q", option) } return callMCPToolOnServer("im", "update_show_history_msg_option", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "option": option, }) }, } DeclareLeafMetadata(chatGroupSetHistoryCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_show_history_msg_option", CanonicalPath: "chat.update_show_history_msg_option", CLIPath: "chat group set-history", PrimaryCLIPath: "chat group set-history", }, Description: "设置新成员可见的群历史消息范围", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_show_history_msg_option"}, }, Selection: contract.SelectionSpec{ AgentSummary: "设置新成员可见的群历史消息范围", UseWhen: []string{"需要调整新成员入群后的历史消息可见性时"}, AvoidWhen: []string{"普通消息查询或群设置的其他开关不要使用"}, Examples: []string{"dws chat group set-history --conversation-id --option RECENT_100"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, }, }, }) chatGroupSetHistoryCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupSetHistoryCmd.MarkFlagRequired("conversation-id") chatGroupSetHistoryCmd.Flags().String("option", "", "可见范围: FORBIDDEN | RECENT_100 | ALL (必填)") _ = chatGroupSetHistoryCmd.MarkFlagRequired("option") chatMessageCombineForwardCmd := &cobra.Command{ Use: "combine-forward", Short: "合并转发多条消息", Long: `将多条消息合并后转发到目标会话。需要指定源会话 ID、源消息 ID 列表(逗号分隔)、目标会话 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (openDingTalkId 可通过 dws contact user search --query "姓名" 获取)`, Example: ` dws chat message combine-forward --src-conversation-id --msg-ids ,, --dest-conversation-id dws chat message combine-forward --src-conversation-id --msg-ids , --dest-conversation-id --uuid `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-ids", "dest-conversation-id"); err != nil { return err } toolArgs := map[string]any{ "srcOpenCid": mustGetFlag(cmd, "src-conversation-id"), "srcOpenMessageIds": parseCSVValues(mustGetFlag(cmd, "msg-ids")), "destOpenCid": mustGetFlag(cmd, "dest-conversation-id"), } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v } return callMCPToolOnServer("im", "combine_forward_messages", toolArgs) }, } DeclareLeafMetadata(chatMessageCombineForwardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "combine_forward_messages", CanonicalPath: "chat.combine_forward_messages", CLIPath: "chat message combine-forward", PrimaryCLIPath: "chat message combine-forward", }, Description: "把多条消息合并转发到目标会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "combine_forward_messages"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把多条消息合并转发到目标会话", UseWhen: []string{"需要保留多条源消息并作为合集转发时"}, AvoidWhen: []string{"只转发单条消息时使用 chat message forward"}, Examples: []string{"dws chat message combine-forward --src-conversation-id --msg-ids , --dest-conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "dest-conversation-id", Property: "destOpenCid"}, {Name: "msg-ids", Property: "srcOpenMessageIds"}, {Name: "src-conversation-id", Property: "srcOpenCid"}, }, }, }) chatMessageCombineForwardCmd.Flags().String("src-conversation-id", "", "源会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageCombineForwardCmd.MarkFlagRequired("src-conversation-id") chatMessageCombineForwardCmd.Flags().String("msg-ids", "", "源消息 openMessageId 列表,逗号分隔 (必填)") _ = chatMessageCombineForwardCmd.MarkFlagRequired("msg-ids") chatMessageCombineForwardCmd.Flags().String("dest-conversation-id", "", "目标会话 openConversationId (必填,支持单聊/群聊)") _ = chatMessageCombineForwardCmd.MarkFlagRequired("dest-conversation-id") chatMessageCombineForwardCmd.Flags().String("uuid", "", "幂等键(可选)") // ── message forward-topic: 转发话题消息 ───────────────────── chatMessageForwardTopicCmd := &cobra.Command{ Use: "forward-topic", Short: "转发话题消息到目标会话", Long: `将一条话题消息从源会话转发到目标会话。需要指定源消息 ID、源会话 ID、话题 ID、目标会话 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id (openDingTalkId 可通过 dws contact user search --query "姓名" 获取) 话题 ID(srcOpenConvThreadId)格式为 "convThread" + 加密后的 convThreadId, 可通过 dws chat message list 返回的话题信息获取。`, Example: ` dws chat message forward-topic --src-msg-id --src-conversation-id --src-thread-id --dest-conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "src-msg-id", "src-conversation-id", "src-thread-id", "dest-conversation-id"); err != nil { return err } toolArgs := map[string]any{ "srcOpenMessageId": mustGetFlag(cmd, "src-msg-id"), "srcOpenConversationId": mustGetFlag(cmd, "src-conversation-id"), "srcOpenConvThreadId": mustGetFlag(cmd, "src-thread-id"), "destOpenConversationId": mustGetFlag(cmd, "dest-conversation-id"), } return callMCPToolOnServer("im", "forward_topic", toolArgs) }, } DeclareLeafMetadata(chatMessageForwardTopicCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "forward_topic", CanonicalPath: "chat.forward_topic", CLIPath: "chat message forward-topic", PrimaryCLIPath: "chat message forward-topic", }, Description: "把一个话题消息转发到目标会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "forward_topic"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把一个话题消息转发到目标会话", UseWhen: []string{"需要转发话题圈中的主题及上下文时"}, AvoidWhen: []string{"普通单条消息转发使用 chat message forward"}, Examples: []string{"dws chat message forward-topic --src-msg-id --src-conversation-id --src-thread-id --dest-conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "dest-conversation-id", Property: "destOpenConversationId"}, {Name: "src-conversation-id", Property: "srcOpenConversationId"}, {Name: "src-msg-id", Property: "srcOpenMessageId"}, {Name: "src-thread-id", Property: "srcOpenConvThreadId"}, }, }, }) chatMessageForwardTopicCmd.Flags().String("src-msg-id", "", "源消息 openMessageId (必填,要转发的消息)") _ = chatMessageForwardTopicCmd.MarkFlagRequired("src-msg-id") chatMessageForwardTopicCmd.Flags().String("src-conversation-id", "", "源会话 openConversationId (必填,消息所在的会话)") _ = chatMessageForwardTopicCmd.MarkFlagRequired("src-conversation-id") chatMessageForwardTopicCmd.Flags().String("src-thread-id", "", "话题 ID (必填,格式: convThread + 加密后的convThreadId)") _ = chatMessageForwardTopicCmd.MarkFlagRequired("src-thread-id") chatMessageForwardTopicCmd.Flags().String("dest-conversation-id", "", "目标会话 openConversationId (必填,转发到的会话)") _ = chatMessageForwardTopicCmd.MarkFlagRequired("dest-conversation-id") // ── message pin: 钉住/取消钉住/拉取钉住消息 ────────────── chatMessageSetPinCmd := &cobra.Command{ Use: "set-pin-msg", Short: "钉住消息(Pin)", Long: `将指定消息设置为钉住状态。需要指定会话 ID 和消息 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat message set-pin-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "set_pin_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } DeclareLeafMetadata(chatMessageSetPinCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_pin_message", CanonicalPath: "chat.set_pin_message", CLIPath: "chat message set-pin-msg", PrimaryCLIPath: "chat message set-pin-msg", }, Description: "把指定消息设为会话置顶消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_pin_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "把指定消息设为会话置顶消息", UseWhen: []string{"需要在会话中置顶一条已知消息时"}, AvoidWhen: []string{"取消置顶使用 chat message unset-pin-msg"}, Examples: []string{"dws chat message set-pin-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "message-id", Property: "openMessageId"}, }, }, }) chatMessageSetPinCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageSetPinCmd.MarkFlagRequired("open-conversation-id") chatMessageSetPinCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageSetPinCmd.MarkFlagRequired("message-id") chatMessageUnsetPinCmd := &cobra.Command{ Use: "unset-pin-msg", Short: "取消钉住消息(Unpin)", Long: `取消指定消息的钉住状态。需要指定会话 ID 和消息 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat message unset-pin-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "unset_pin_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } DeclareLeafMetadata(chatMessageUnsetPinCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "unset_pin_message", CanonicalPath: "chat.unset_pin_message", CLIPath: "chat message unset-pin-msg", PrimaryCLIPath: "chat message unset-pin-msg", }, Description: "取消指定消息的会话置顶", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "unset_pin_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "取消指定消息的会话置顶", UseWhen: []string{"需要移除一条已知置顶消息时"}, AvoidWhen: []string{"新增置顶使用 chat message set-pin-msg"}, Examples: []string{"dws chat message unset-pin-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "message-id", Property: "openMessageId"}, }, }, }) chatMessageUnsetPinCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageUnsetPinCmd.MarkFlagRequired("open-conversation-id") chatMessageUnsetPinCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageUnsetPinCmd.MarkFlagRequired("message-id") chatMessageListPinCmd := &cobra.Command{ Use: "list-pin-msg", Short: "拉取会话中钉住的消息列表", Long: `拉取指定会话中被钉住的消息列表,支持游标分页。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat message list-pin-msg --open-conversation-id dws chat message list-pin-msg --open-conversation-id --size 50 dws chat message list-pin-msg --open-conversation-id --cursor --size 20`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } if v, _ := cmd.Flags().GetInt("size"); v > 0 { toolArgs["count"] = v } return callMCPToolOnServer("im", "list_pin_messages", toolArgs) }, } DeclareLeafMetadata(chatMessageListPinCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_pin_messages", CanonicalPath: "chat.list_pin_messages", CLIPath: "chat message list-pin-msg", PrimaryCLIPath: "chat message list-pin-msg", }, Description: "列出指定会话中的置顶消息", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_pin_messages"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出指定会话中的置顶消息", UseWhen: []string{"需要查看群聊当前置顶的消息时"}, AvoidWhen: []string{"设置或取消置顶时使用 set-pin-msg 或 unset-pin-msg"}, Examples: []string{"dws chat message list-pin-msg --open-conversation-id --size 50"}, }, Parameters: []contract.ParamDecl{ {Name: "size", Property: "count"}, }, }, }) chatMessageListPinCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageListPinCmd.MarkFlagRequired("open-conversation-id") chatMessageListPinCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页时传上次返回的 nextCursor)") chatMessageListPinCmd.Flags().Int("size", 0, "一次拉取的消息数量(默认 20,最大 100)") // ── message favorites: 收藏/取消收藏/查询收藏消息 ────────── chatMessageAddFavoriteCmd := &cobra.Command{ Use: "add-favorite", Short: "收藏指定消息", Long: `收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。 该操作会修改当前用户的消息收藏状态。`, Example: ` dws chat message add-favorite --open-message-id --open-conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "add_message_favorite", map[string]any{ "openMessageId": mustGetFlag(cmd, "open-message-id"), "openConversationId": mustGetFlag(cmd, "open-conversation-id"), }) }, } DeclareLeafMetadata(chatMessageAddFavoriteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "add_message_favorite", CanonicalPath: "chat.add_message_favorite", CLIPath: "chat message add-favorite", PrimaryCLIPath: "chat message add-favorite", }, Description: "将指定会话中的一条消息加入当前用户的收藏。", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "将指定会话中的一条消息加入当前用户的收藏。", UseWhen: []string{"用户明确要收藏一条已知消息,且已取得消息 ID 与所属会话 ID 时。"}, AvoidWhen: []string{"需要给消息添加表情、置顶消息或发送新消息时不要使用;本命令只修改当前用户的收藏状态。"}, Examples: []string{"dws chat message add-favorite --open-message-id MSG_ID --open-conversation-id CONVERSATION_ID"}, }, }, }) chatMessageAddFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)") _ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-message-id") chatMessageAddFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)") _ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-conversation-id") chatMessageRemoveFavoriteCmd := &cobra.Command{ Use: "remove-favorite", Short: "取消收藏指定消息", Long: `取消收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。 该操作只移除当前用户的收藏标记,不会删除原消息。`, Example: ` dws chat message remove-favorite --open-message-id --open-conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil { return err } return callMCPToolOnServer("im", "remove_message_favorite", map[string]any{ "openMessageId": mustGetFlag(cmd, "open-message-id"), "openConversationId": mustGetFlag(cmd, "open-conversation-id"), }) }, } DeclareLeafMetadata(chatMessageRemoveFavoriteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "remove_message_favorite", CanonicalPath: "chat.remove_message_favorite", CLIPath: "chat message remove-favorite", PrimaryCLIPath: "chat message remove-favorite", }, Description: "取消当前用户对指定消息的收藏标记,不删除原消息。", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "取消当前用户对指定消息的收藏标记,不删除原消息。", UseWhen: []string{"用户明确要从个人收藏中移除一条已知消息时。"}, AvoidWhen: []string{"需要撤回或删除原消息、移除表情回应或取消消息置顶时不要使用。"}, Examples: []string{"dws chat message remove-favorite --open-message-id MSG_ID --open-conversation-id CONVERSATION_ID"}, }, }, }) chatMessageRemoveFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)") _ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-message-id") chatMessageRemoveFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)") _ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-conversation-id") chatMessageListFavoritesCmd := &cobra.Command{ Use: "list-favorites", Short: "查询收藏的消息列表", Long: `查询当前用户收藏的消息列表,支持数字游标分页。 首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。 返回 hasMore=true 时,将数字 nextCursor 作为下一次的 --cursor。默认只读取单页;只有显式传 --page-all 才会自动翻页并聚合 result.items。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 精确截断返回条数,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-favorites dws chat message list-favorites --size 30 dws chat message list-favorites --cursor 20 --size 20 dws chat message list-favorites --size 20 --page-all --page-limit 10 --max-items 100 --page-delay 0`, RunE: func(cmd *cobra.Command, args []string) error { cfg := pagedChatMessagesOnServerConfig("im", "list_message_favorites", chatMessageListFavoritesArgs) cfg.ItemPath = "result.items" cfg.CursorKind = PagedCursorInt64 return RunPagedMCPCommand(cmd, cfg) }, } DeclareLeafMetadata(chatMessageListFavoritesCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_message_favorites", CanonicalPath: "chat.list_message_favorites", CLIPath: "chat message list-favorites", PrimaryCLIPath: "chat message list-favorites", }, Description: "分页查询当前用户收藏的消息列表。", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "分页查询当前用户收藏的消息列表。", UseWhen: []string{"需要查看当前用户已经收藏的消息,或使用 nextCursor 继续翻页时。"}, AvoidWhen: []string{"需要搜索普通聊天记录、置顶消息或修改收藏状态时不要使用。"}, Examples: []string{ "dws chat message list-favorites --cursor 0 --size 20", "dws chat message list-favorites --size 20 --page-all --page-limit 10", }, }, Parameters: append([]contract.ParamDecl{ {Name: "size", InterfaceType: "string"}, }, pagedMCPParamDecls()...), }, }) chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)") chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)") AddPagedMCPFlags(chatMessageListFavoritesCmd) // ── group list-my-groups: 拉取我创建/管理的群 ────────────── chatGroupListMyGroupsCmd := &cobra.Command{ Use: "list-my-groups", Short: "拉取我创建/管理的群", Long: `拉取当前用户作为群主或管理员的群列表。 可通过 --role 过滤角色:OWNER 仅群主、ADMIN 仅管理员,不传则返回全部。 可通过 --limit 限制返回数量,不传则返回所有符合条件的群。`, Example: ` dws chat group list-my-groups dws chat group list-my-groups --role OWNER dws chat group list-my-groups --role ADMIN --limit 10`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if v, _ := cmd.Flags().GetString("role"); v != "" { if v != "OWNER" && v != "ADMIN" { return apperrors.NewValidation("--role must be one of OWNER or ADMIN") } toolArgs["roleFilter"] = v } if v, _ := cmd.Flags().GetInt("limit"); v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPToolOnServer("im", "list_owned_or_admin_groups", toolArgs) }, } DeclareLeafMetadata(chatGroupListMyGroupsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_owned_or_admin_groups", CanonicalPath: "chat.list_owned_or_admin_groups", CLIPath: "chat group list-my-groups", PrimaryCLIPath: "chat group list-my-groups", }, Description: "列出当前用户创建或管理的群聊", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_owned_or_admin_groups"}, }, Selection: contract.SelectionSpec{ AgentSummary: "列出当前用户创建或管理的群聊", UseWhen: []string{"需要按群主或管理员角色盘点群聊时"}, AvoidWhen: []string{"按名称搜索任意可见群时使用 chat search"}, Examples: []string{"dws chat group list-my-groups --role OWNER --limit 100"}, }, Parameters: []contract.ParamDecl{ {Name: "limit", Required: boolPtr(false)}, {Name: "role", Property: "roleFilter", Required: boolPtr(false)}, }, }, }) chatGroupListMyGroupsCmd.Flags().String("role", "", "角色过滤: OWNER(仅群主) / ADMIN(仅管理员),不传返回全部") chatGroupListMyGroupsCmd.Flags().Int("limit", 0, "最多返回群数量,不传返回全部") chatGroupListMyGroupsCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") // ── group list-all: 分页拉取我所有群列表 ─────────────────────── chatGroupListAllCmd := &cobra.Command{ Use: "list-all", Short: "分页拉取我所有群列表", Long: `分页获取当前用户加入的所有群聊列表。 支持分页,每次最多返回 200 个群。`, Example: ` dws chat group list-all dws chat group list-all --limit 50 dws chat group list-all --limit 100 --cursor `, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if v, _ := cmd.Flags().GetInt("limit"); v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetString("cursor"); v != "" && v != "0" { toolArgs["cursor"] = v } return callMCPToolOnServer("im", "list_my_groups_pagination", toolArgs) }, } chatGroupListAllCmd.Flags().Int("limit", 100, "每页返回数量(默认 100,最大 200)") chatGroupListAllCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextCursor)") DeclareLeafMetadata(chatGroupListAllCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_my_groups_pagination", CanonicalPath: "chat.list_my_groups_pagination", CLIPath: "chat group list-all", PrimaryCLIPath: "chat group list-all", }, Description: "分页获取当前用户加入的所有群聊列表", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_my_groups_pagination"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页获取当前用户加入的所有群聊列表", UseWhen: []string{"需要完整翻页盘点当前用户加入的所有群聊时"}, AvoidWhen: []string{"只按群主/管理员角色列群时使用 chat group list-my-groups"}, Examples: []string{"dws chat group list-all --limit 100"}, }, Parameters: []contract.ParamDecl{ {Name: "cursor", Property: "cursor", Required: boolPtr(false)}, {Name: "limit", Property: "limit", Required: boolPtr(false)}, }, }, }) // ── group list-join-validations: 分页拉取入群验证记录 ───────── chatGroupListJoinValidationsCmd := &cobra.Command{ Use: "list-join-validations", Short: "分页拉取入群验证记录", Long: `分页拉取当前用户的所有入群验证记录,包括自己被拒绝的记录以及作为审批者的记录。 支持分页,每页最多返回 50 条。`, Example: ` dws chat group list-join-validations dws chat group list-join-validations --limit 30 dws chat group list-join-validations --limit 20 --cursor `, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if v, _ := cmd.Flags().GetInt("limit"); v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } return callMCPToolOnServer("im", "list_apply_join_group_records", toolArgs) }, } chatGroupListJoinValidationsCmd.Flags().Int("limit", 20, "单页数量(默认 20,最大 50)") chatGroupListJoinValidationsCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextCursor)") DeclareLeafMetadata(chatGroupListJoinValidationsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_apply_join_group_records", CanonicalPath: "chat.list_apply_join_group_records", CLIPath: "chat group list-join-validations", PrimaryCLIPath: "chat group list-join-validations", }, Description: "分页拉取当前用户相关的入群验证记录", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_apply_join_group_records"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页拉取当前用户相关的入群验证记录", UseWhen: []string{"需要查看待处理、被拒绝或已处理的入群申请记录时"}, AvoidWhen: []string{"需要审批某条记录时使用 chat group audit-join-validation"}, Examples: []string{"dws chat group list-join-validations --limit 20"}, }, Parameters: []contract.ParamDecl{ {Name: "cursor", Property: "cursor", Required: boolPtr(false)}, {Name: "limit", Property: "limit", Required: boolPtr(false)}, }, }, }) // ── group audit-join-validation: 审批入群验证 ───────────────────────── chatGroupAuditJoinValidationCmd := &cobra.Command{ Use: "audit-join-validation", Short: "审批入群验证(通过、拒绝、删除)", Long: `审批入群验证。真机实测服务端仅接受 AuditApprove / AuditDelete,其余状态会被拒绝(unsupported audit status)。 status 可选值: AuditApprove — 通过(可用) AuditDelete — 删除(可用) AuditIgnore — 忽略(服务端拒绝,不可用) AuditRefuse — 拒绝(服务端拒绝,不可用) AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`, Example: ` dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditApprove dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditDelete --description "不符合入群条件" # 查询入群验证记录: dws chat group list-join-validations`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group"); err != nil { return err } if err := validateRequiredFlags(cmd, "record-id", "applicant", "inviter", "status"); err != nil { return err } recordID, err := strconv.ParseInt(mustGetFlag(cmd, "record-id"), 10, 64) if err != nil { return fmt.Errorf("--record-id must be a valid integer: %w", err) } status := mustGetFlag(cmd, "status") if status != "AuditApprove" && status != "AuditDelete" { return fmt.Errorf("unsupported audit status %q, must be one of: AuditApprove, AuditDelete", status) } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group"), "applyRecordId": recordID, "applicantUid": mustGetFlag(cmd, "applicant"), "inviterUid": mustGetFlag(cmd, "inviter"), "status": status, } if v, _ := cmd.Flags().GetString("description"); v != "" { toolArgs["auditDescription"] = v } return callMCPToolOnServer("im", "audit_join_group", toolArgs) }, } corecmd.RegisterFlags(chatGroupAuditJoinValidationCmd, []LeafFlag{{ Name: "conversation-id", Usage: "群 openConversationId (必填)", Required: true, Aliases: []string{"group"}, }}) _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("conversation-id") chatGroupAuditJoinValidationCmd.Flags().String("record-id", "", "申请记录 ID (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id") chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("status") chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 userId (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("applicant") chatGroupAuditJoinValidationCmd.Flags().String("inviter", "", "邀请人 userId (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("inviter") chatGroupAuditJoinValidationCmd.Flags().String("description", "", "审批说明(可选)") DeclareLeafMetadata(chatGroupAuditJoinValidationCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "audit_join_group", CanonicalPath: "chat.audit_join_group", CLIPath: "chat group audit-join-validation", PrimaryCLIPath: "chat group audit-join-validation", }, Description: "审批入群验证记录", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "audit_join_group"}, }, Selection: contract.SelectionSpec{ AgentSummary: "审批入群验证记录", UseWhen: []string{"需要对已知入群申请记录执行通过或删除审批动作时"}, AvoidWhen: []string{"还没有 record-id 时先用 chat group list-join-validations 查询"}, Examples: []string{"dws chat group audit-join-validation --conversation-id --record-id 123456 --applicant --inviter --status AuditApprove"}, }, Parameters: []contract.ParamDecl{ {Name: "applicant", Property: "applicantUid", Required: boolPtr(true)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "description", Property: "auditDescription", Required: boolPtr(false)}, {Name: "inviter", Property: "inviterUid", Required: boolPtr(true)}, {Name: "record-id", Property: "applyRecordId", Required: boolPtr(true), InterfaceType: "integer"}, {Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete"}}, }, }, }) // ── mark-unread: 标记会话为未读 ─────────────────────────── chatMarkUnreadCmd := &cobra.Command{ Use: "mark-unread", Short: "标记会话为未读", Long: `将指定会话标记为未读状态。支持群聊和单聊。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat mark-unread --conversation-id dws chat mark-unread --id `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mark-unread --conversation-id ") } return callMCPToolOnServer("im", "mark_conversation_unread", map[string]any{ "openConversationId": convID, }) }, } chatMarkUnreadCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") chatMarkUnreadCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMarkUnreadCmd.Flags().MarkHidden("id") chatMarkUnreadCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMarkUnreadCmd.Flags().MarkHidden("chat") DeclareLeafMetadata(chatMarkUnreadCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "mark_conversation_unread", CanonicalPath: "chat." + "mark_conversation_unread", CLIPath: "chat mark-unread", PrimaryCLIPath: "chat mark-unread", }, Description: "将指定会话标记为未读", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "mark_conversation_unread"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将指定会话标记为未读", UseWhen: []string{"用户明确要把某个群聊或单聊标记为未读时"}, AvoidWhen: []string{"需要标记某条消息及之前消息已读时使用 chat mark-read"}, Examples: []string{"dws chat mark-unread --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) // ── clear-red-point: 清除会话红点 ────────────────────────── chatClearRedPointCmd := &cobra.Command{ Use: "clear-red-point", Short: "清除会话红点", Long: `清除指定会话的未读红点。支持群聊和单聊。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat clear-red-point --conversation-id dws chat clear-red-point --id `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-red-point --conversation-id ") } return callMCPToolOnServer("im", "clear_conversation_red_point", map[string]any{ "openConversationId": convID, }) }, } chatClearRedPointCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") chatClearRedPointCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatClearRedPointCmd.Flags().MarkHidden("id") chatClearRedPointCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatClearRedPointCmd.Flags().MarkHidden("chat") DeclareLeafMetadata(chatClearRedPointCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "clear_conversation_red_point", CanonicalPath: "chat." + "clear_conversation_red_point", CLIPath: "chat clear-red-point", PrimaryCLIPath: "chat clear-red-point", }, Description: "清除指定会话未读红点", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "clear_conversation_red_point"}, }, Selection: contract.SelectionSpec{ AgentSummary: "清除指定会话未读红点", UseWhen: []string{"用户只想清掉某个会话的未读红点时"}, AvoidWhen: []string{"需要清除所有会话红点时使用 chat clear-all-red-point"}, Examples: []string{"dws chat clear-red-point --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) // ── clear-all-red-point: 红点清零(全部已读) ───────────────── chatClearAllRedPointCmd := &cobra.Command{ Use: "clear-all-red-point", Short: "清除所有会话红点(全部已读)", Long: `一键清除当前用户所有会话的未读红点,等效于“全部已读”。`, Example: ` dws chat clear-all-red-point`, RunE: func(cmd *cobra.Command, args []string) error { return callMCPToolOnServer("im", "clear_all_red_point", map[string]any{}) }, } DeclareLeafMetadata(chatClearAllRedPointCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "clear_all_red_point", CanonicalPath: "chat." + "clear_all_red_point", CLIPath: "chat clear-all-red-point", PrimaryCLIPath: "chat clear-all-red-point", }, Description: "清除当前用户所有会话未读红点", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "clear_all_red_point"}, }, Selection: contract.SelectionSpec{ AgentSummary: "清除当前用户所有会话未读红点", UseWhen: []string{"用户明确要求一键清除所有会话未读红点或全部已读时"}, AvoidWhen: []string{"只处理单个会话红点时使用 chat clear-red-point"}, Examples: []string{"dws chat clear-all-red-point"}, }, }, }) // ── list-all-conversations: 分页获取全部会话 ──────────────────── chatListAllConversationsCmd := &cobra.Command{ Use: "list-all-conversations", Short: "分页获取当前用户的全部会话列表", Long: `分页获取当前用户的全部会话列表(包含单聊和群聊)。--limit 指定每页数量(1-100,默认 100),--cursor 传分页游标(首次不传或传 0)。 返回 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`, Example: ` dws chat list-all-conversations dws chat list-all-conversations --limit 50 dws chat list-all-conversations --limit 100 --cursor dws chat list-all-conversations --exclude-muted`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{} if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 { // 服务端每页上限 100,超过会被静默截断,这里显式拒绝以免误以为取全。 if v > 100 { return fmt.Errorf("--limit 最大 100(服务端上限),got %d;如需取全部会话请配合 --cursor 翻页", v) } toolArgs["limit"] = v } if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 { toolArgs["cursor"] = v } if v, _ := cmd.Flags().GetBool("exclude-muted"); v { toolArgs["excludeMuted"] = true } return callMCPToolOnServer("im", "list_all_conversations", toolArgs) }, } chatListAllConversationsCmd.Flags().Int("limit", 100, "每页数量(1-100,默认 100)") chatListAllConversationsCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)") chatListAllConversationsCmd.Flags().Bool("exclude-muted", false, "是否排除已免打扰会话(默认 false)") DeclareLeafMetadata(chatListAllConversationsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_all_conversations", CanonicalPath: "chat.list_all_conversations", CLIPath: "chat list-all-conversations", PrimaryCLIPath: "chat list-all-conversations", }, Description: "分页获取当前用户的全部会话列表", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_all_conversations"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页获取当前用户的全部会话列表", UseWhen: []string{"需要枚举当前用户全部单聊和群聊会话并按 cursor 翻页时"}, AvoidWhen: []string{"只搜索群聊时使用 chat search;只查某个会话详情时使用 chat conversation-info"}, Examples: []string{"dws chat list-all-conversations --limit 100"}, }, Parameters: []contract.ParamDecl{ {Name: "cursor", Property: "cursor", Required: boolPtr(false), InterfaceType: "integer"}, {Name: "exclude-muted", Property: "excludeMuted", Required: boolPtr(false), InterfaceType: "boolean"}, {Name: "limit", Property: "limit", Required: boolPtr(false), InterfaceType: "integer"}, }, }, }) // ── clear-messages: 清空会话聊天记录 ──────────────────────────── chatClearMessagesCmd := &cobra.Command{ Use: "clear-messages", Short: "清空当前用户指定会话的聊天记录", Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat clear-messages --conversation-id dws chat clear-messages --id `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id ") } if !commandBoolFlag(cmd, "yes") { return apperrors.NewValidation( "清空会话聊天记录不可逆;获得用户确认后加 --yes 执行", apperrors.WithReason("confirmation_required"), apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"), apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"), ) } return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{ "openConversationId": convID, }) }, } chatClearMessagesCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") chatClearMessagesCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatClearMessagesCmd.Flags().MarkHidden("id") chatClearMessagesCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatClearMessagesCmd.Flags().MarkHidden("chat") DeclareLeafMetadata(chatClearMessagesCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "clear_conversation_messages", CanonicalPath: "chat.clear_conversation_messages", CLIPath: "chat clear-messages", PrimaryCLIPath: "chat clear-messages", }, Description: "清空当前用户指定会话中的聊天记录", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "clear_conversation_messages"}, }, Selection: contract.SelectionSpec{ AgentSummary: "清空当前用户指定会话中的聊天记录", UseWhen: []string{"用户明确要求清空某个会话在当前用户视角的聊天记录,并已确认不可逆影响时"}, AvoidWhen: []string{"只清除未读红点时使用 chat clear-red-point 或 chat clear-all-red-point"}, Examples: []string{"dws chat clear-messages --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) // ── mark-read: 标记消息已读 ──────────────────────────────────── chatMarkReadCmd := &cobra.Command{ Use: "mark-read", Short: "标记消息已读", Long: `标记指定会话中某条消息为已读。该消息及之前的所有消息都会被标记为已读。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id 如何获取 openMessageId: - dws chat message list --conversation-id --time "2025-03-01 00:00:00"`, Example: ` dws chat mark-read --conversation-id --message-id dws chat mark-read --id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mark-read --conversation-id --message-id ") } messageID, err := chatMessageID(cmd) if err != nil { return err } return callMCPToolOnServer("im", "mark_message_read", map[string]any{ "openConversationId": convID, "openMessageId": messageID, }) }, } chatMarkReadCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") chatMarkReadCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMarkReadCmd.Flags().MarkHidden("id") chatMarkReadCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMarkReadCmd.Flags().MarkHidden("chat") chatMarkReadCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMarkReadCmd.MarkFlagRequired("message-id") DeclareLeafMetadata(chatMarkReadCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "mark_message_read", CanonicalPath: "chat." + "mark_message_read", CLIPath: "chat mark-read", PrimaryCLIPath: "chat mark-read", }, Description: "将指定消息及之前消息标记为已读", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "mark_message_read"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将指定消息及之前消息标记为已读", UseWhen: []string{"已有会话和消息 ID,需要把该消息及之前消息标记已读时"}, AvoidWhen: []string{"只标记会话为未读时使用 chat mark-unread"}, Examples: []string{"dws chat mark-read --conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, }, }, }) // ── set-top-msg: 置顶某条消息 ──────────────────────────────── chatMessageSetTopMsgCmd := &cobra.Command{ Use: "set-top-msg", Short: "置顶消息", Long: `将指定消息设置为置顶状态。需要指定会话 ID 和消息 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat message set-top-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "set_top_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } chatMessageSetTopMsgCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageSetTopMsgCmd.MarkFlagRequired("open-conversation-id") chatMessageSetTopMsgCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageSetTopMsgCmd.MarkFlagRequired("message-id") DeclareLeafMetadata(chatMessageSetTopMsgCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "set_top_message", CanonicalPath: "chat." + "set_top_message", CLIPath: "chat message set-top-msg", PrimaryCLIPath: "chat message set-top-msg", }, Description: "将指定会话消息设置为置顶", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "set_top_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将指定会话消息设置为置顶", UseWhen: []string{"需要把群聊或单聊中的某条消息置顶展示时"}, AvoidWhen: []string{"取消消息置顶时使用 chat message unset-top-msg"}, Examples: []string{"dws chat message set-top-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(true)}, }, }, }) // ── unset-top-msg: 取消置顶某条消息 ───────────────────────── chatMessageUnsetTopMsgCmd := &cobra.Command{ Use: "unset-top-msg", Short: "取消置顶消息", Long: `取消指定消息的置顶状态。需要指定会话 ID 和消息 ID。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" - 单聊:dws chat conversation-info --open-dingtalk-id `, Example: ` dws chat message unset-top-msg --open-conversation-id --message-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-conversation-id"); err != nil { return err } if _, err := chatMessageID(cmd); err != nil { return err } return callMCPToolOnServer("im", "unset_top_message", map[string]any{ "openConversationId": mustGetFlag(cmd, "open-conversation-id"), "openMessageId": flagOrFallback(cmd, "message-id", "msg-id"), }) }, } chatMessageUnsetTopMsgCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填,支持群聊/单聊)") _ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("open-conversation-id") chatMessageUnsetTopMsgCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("message-id") DeclareLeafMetadata(chatMessageUnsetTopMsgCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "unset_top_message", CanonicalPath: "chat." + "unset_top_message", CLIPath: "chat message unset-top-msg", PrimaryCLIPath: "chat message unset-top-msg", }, Description: "取消指定会话消息的置顶", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "unset_top_message"}, }, Selection: contract.SelectionSpec{ AgentSummary: "取消指定会话消息的置顶", UseWhen: []string{"需要取消某条消息的置顶状态时"}, AvoidWhen: []string{"设置消息置顶时使用 chat message set-top-msg"}, Examples: []string{"dws chat message unset-top-msg --open-conversation-id --message-id "}, }, Parameters: []contract.ParamDecl{ {Name: "message-id", Property: "openMessageId", Required: boolPtr(true)}, {Name: "open-conversation-id", Property: "openConversationId", Required: boolPtr(true)}, }, }, }) // group 与 members 的子命令在下方(chatGroupCmd.AddCommand / chatGroupMembersCmd.AddCommand // 的完整列表处)统一注册;此处不再重复 AddCommand,否则 --help 会重复列出。 // ── group update-nick: 设置用户在群内的群昵称 ────────────── chatGroupUpdateNickCmd := &cobra.Command{ Use: "update-nick", Short: "设置或清除用户在群内的群昵称", Long: `设置当前用户在指定群聊内的个人群昵称。不传 --nick 时清除当前群昵称。`, Example: ` dws chat group update-nick --conversation-id --nick "我的群昵称" dws chat group update-nick --conversation-id # 不传 --nick 表示清除群昵称 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } nick, _ := cmd.Flags().GetString("nick") return callMCPToolOnServer("im", "update_group_nick", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "nick": nick, }) }, } DeclareLeafMetadata(chatGroupUpdateNickCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_group_nick", CanonicalPath: "chat.update_group_nick", CLIPath: "chat group update-nick", PrimaryCLIPath: "chat group update-nick", }, Description: "设置或清除当前用户在指定群内的昵称", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI maps nickname update or clear semantics to im/update_group_nick, which is absent from the pinned MCP metadata snapshot.", }, Selection: contract.SelectionSpec{ AgentSummary: "设置或清除当前用户在指定群内的昵称", UseWhen: []string{"用户要求修改自己的群昵称,或明确要求清除群昵称"}, AvoidWhen: []string{"修改群名称应使用 chat group rename;修改其他成员信息不应使用本命令"}, Examples: []string{ "dws chat group update-nick --conversation-id --nick \"项目昵称\"", "dws chat group update-nick --conversation-id ", }, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "nick", Property: "nick", Required: boolPtr(false)}, }, }, }) chatGroupUpdateNickCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupUpdateNickCmd.MarkFlagRequired("conversation-id") chatGroupUpdateNickCmd.Flags().String("nick", "", "个人群昵称,不传则清除群昵称") // ── group update-alias: 设置群备注 ────────────────────────── chatGroupUpdateAliasCmd := &cobra.Command{ Use: "update-alias", Short: "设置群备注", Long: `设置当前用户对指定群聊的备注名称(仅自己可见)。`, Example: ` dws chat group update-alias --conversation-id --alias-title "项目A群" # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "alias-title"); err != nil { return err } return callMCPToolOnServer("im", "update_user_group_alias", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "aliasTitle": mustGetFlag(cmd, "alias-title"), }) }, } chatGroupUpdateAliasCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupUpdateAliasCmd.MarkFlagRequired("conversation-id") chatGroupUpdateAliasCmd.Flags().String("alias-title", "", "群备注标题 (必填)") _ = chatGroupUpdateAliasCmd.MarkFlagRequired("alias-title") DeclareLeafMetadata(chatGroupUpdateAliasCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_user_group_alias", CanonicalPath: "chat.update_user_group_alias", CLIPath: "chat group update-alias", PrimaryCLIPath: "chat group update-alias", }, Description: "设置当前用户可见的群备注名称", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_user_group_alias"}, }, Selection: contract.SelectionSpec{ AgentSummary: "设置当前用户可见的群备注名称", UseWhen: []string{"用户要求给指定群设置仅自己可见的备注名时"}, AvoidWhen: []string{"修改群公开名称应使用 chat group rename"}, Examples: []string{"dws chat group update-alias --conversation-id --alias-title \"项目A群\""}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "alias-title", Property: "aliasTitle", Required: boolPtr(true)}, }, }, }) // ── hide: 会话列表中隐藏会话 ──────────────────────────────── chatHideCmd := &cobra.Command{ Use: "hide", Short: "会话列表中隐藏会话", Long: `在会话列表中隐藏指定会话(支持单聊/群聊)。隐藏后会话不再显示在列表中,收到新消息时会重新出现。`, Example: ` dws chat hide --conversation-id dws chat hide --id # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user `, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat hide --conversation-id ") } return callMCPToolOnServer("im", "hide_conversation", map[string]any{ "openConversationId": convID, }) }, } chatHideCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatHideCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatHideCmd.Flags().MarkHidden("id") chatHideCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatHideCmd.Flags().MarkHidden("chat") DeclareLeafMetadata(chatHideCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "hide_conversation", CanonicalPath: "chat.hide_conversation", CLIPath: "chat hide", PrimaryCLIPath: "chat hide", }, Description: "在会话列表中隐藏指定会话", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_conversation"}, }, Selection: contract.SelectionSpec{ AgentSummary: "在会话列表中隐藏指定会话", UseWhen: []string{"用户要求从当前会话列表隐藏某个单聊或群聊时"}, AvoidWhen: []string{"只想关闭通知提醒时使用 chat mute 或 chat mute-at-all"}, Examples: []string{"dws chat hide --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, }, }, }) // ── mute-at-all: 关闭/开启 @所有人通知 ───────────────────── chatMuteAtAllCmd := &cobra.Command{ Use: "mute-at-all", Short: "关闭/开启 @所有人消息提醒", Long: `关闭或开启会话中 @所有人的消息通知。默认关闭通知,传 --off 则恢复接收通知。`, Example: ` dws chat mute-at-all --conversation-id dws chat mute-at-all --conversation-id --off # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mute-at-all --conversation-id ") } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "update_at_all_notification_off", map[string]any{ "openConversationId": convID, "mute": !off, }) }, } chatMuteAtAllCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMuteAtAllCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMuteAtAllCmd.Flags().MarkHidden("id") chatMuteAtAllCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMuteAtAllCmd.Flags().MarkHidden("chat") chatMuteAtAllCmd.Flags().Bool("off", false, "恢复接收 @所有人通知(不传则关闭通知)") DeclareLeafMetadata(chatMuteAtAllCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_at_all_notification_off", CanonicalPath: "chat.update_at_all_notification_off", CLIPath: "chat mute-at-all", PrimaryCLIPath: "chat mute-at-all", }, Description: "关闭或恢复会话中的 @所有人消息提醒", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_at_all_notification_off"}, }, Selection: contract.SelectionSpec{ AgentSummary: "关闭或恢复会话中的 @所有人消息提醒", UseWhen: []string{"用户要求关闭或恢复指定会话的 @所有人提醒时"}, AvoidWhen: []string{"关闭普通消息免打扰使用 chat mute;关闭红包提醒使用 chat mute-red-envelope"}, Examples: []string{"dws chat mute-at-all --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "off", Property: "mute", Required: boolPtr(false), InterfaceType: "boolean"}, }, }, }) // ── mute-red-envelope: 关闭/开启红包通知 ──────────────────── chatMuteRedEnvelopeCmd := &cobra.Command{ Use: "mute-red-envelope", Short: "关闭/开启红包消息提醒", Long: `关闭或开启会话中的红包消息通知。默认关闭通知,传 --off 则恢复接收通知。`, Example: ` dws chat mute-red-envelope --conversation-id dws chat mute-red-envelope --conversation-id --off # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { convID := flagOrFallback(cmd, "conversation-id", "id", "chat") if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat mute-red-envelope --conversation-id ") } off, _ := cmd.Flags().GetBool("off") return callMCPToolOnServer("im", "update_red_env_notification_off", map[string]any{ "openConversationId": convID, "mute": !off, }) }, } chatMuteRedEnvelopeCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)") chatMuteRedEnvelopeCmd.Flags().String("id", "", "--conversation-id 的别名") _ = chatMuteRedEnvelopeCmd.Flags().MarkHidden("id") chatMuteRedEnvelopeCmd.Flags().String("chat", "", "--conversation-id 的别名") _ = chatMuteRedEnvelopeCmd.Flags().MarkHidden("chat") chatMuteRedEnvelopeCmd.Flags().Bool("off", false, "恢复接收红包通知(不传则关闭通知)") DeclareLeafMetadata(chatMuteRedEnvelopeCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "update_red_env_notification_off", CanonicalPath: "chat.update_red_env_notification_off", CLIPath: "chat mute-red-envelope", PrimaryCLIPath: "chat mute-red-envelope", }, Description: "关闭或恢复会话中的红包消息提醒", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_red_env_notification_off"}, }, Selection: contract.SelectionSpec{ AgentSummary: "关闭或恢复会话中的红包消息提醒", UseWhen: []string{"用户要求关闭或恢复指定会话的红包提醒时"}, AvoidWhen: []string{"关闭 @所有人提醒使用 chat mute-at-all;关闭普通消息免打扰使用 chat mute"}, Examples: []string{"dws chat mute-red-envelope --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "chat", Property: "openConversationId", Required: boolPtr(false)}, {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "id", Property: "openConversationId", Required: boolPtr(false)}, {Name: "off", Property: "mute", Required: boolPtr(false), InterfaceType: "boolean"}, }, }, }) // ── group members list-by-ids: 批量查看群成员详情 ─────────── chatGroupMembersListByIdsCmd := &cobra.Command{ Use: "list-by-ids", Short: "根据成员 ID 批量查询群成员详情", Long: `根据群 openConversationId 和成员 openDingTalkId 列表,批量查询群成员详情信息。`, Example: ` dws chat group members list-by-ids --id --users openDingTalkId1,openDingTalkId2 # 查询群 ID: dws chat search --query "群名" # 查询 openDingTalkId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "id", "users"); err != nil { return err } users := parseCSVValues(mustGetFlag(cmd, "users")) return callMCPToolOnServer("im", "list_group_member_by_ids", map[string]any{ "openConversationId": mustGetFlag(cmd, "id"), "memberOpenDingTalkIds": users, }) }, } chatGroupMembersListByIdsCmd.Flags().String("id", "", "群 ID / openConversationId (必填)") _ = chatGroupMembersListByIdsCmd.MarkFlagRequired("id") chatGroupMembersListByIdsCmd.Flags().String("users", "", "成员 openDingTalkId 列表,逗号分隔 (必填)") _ = chatGroupMembersListByIdsCmd.MarkFlagRequired("users") DeclareLeafMetadata(chatGroupMembersListByIdsCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_group_member_by_ids", CanonicalPath: "chat.list_group_member_by_ids", CLIPath: "chat group members list-by-ids", PrimaryCLIPath: "chat group members list-by-ids", }, Description: "按成员 openDingTalkId 批量查询群成员详情", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_group_member_by_ids"}, }, Selection: contract.SelectionSpec{ AgentSummary: "按成员 openDingTalkId 批量查询群成员详情", UseWhen: []string{"已知群 ID 和一组成员 openDingTalkId,需要批量查看这些成员资料时"}, AvoidWhen: []string{"需要列出群内全部成员时使用 chat group members list"}, Examples: []string{"dws chat group members list-by-ids --id --users openDingTalkId1,openDingTalkId2"}, }, Parameters: []contract.ParamDecl{ {Name: "id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "users", Property: "memberOpenDingTalkIds", Required: boolPtr(true), InterfaceType: "array"}, }, }, }) // ── group notice: 群公告管理 ──────────────────────────────── chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE} chatGroupNoticeCreateCmd := &cobra.Command{ Use: "create", Short: "发布群公告", Long: `在指定群聊中发布群公告,正文为 Markdown 格式。 支持标题、加粗、斜体、删除线、行内代码、链接、代码块、有序/无序/任务列表、表格、引用、分割线、图片、段落、换行。 定时发布:传 --run-at 指定执行时间点,ISO-8601 格式(建议带时区偏移,不带时按北京时区处理)。`, Example: ` dws chat group notice create --conversation-id --content "今晚 22 点系统维护,请提前保存工作内容" dws chat group notice create --conversation-id --content "# 重要通知\n请大家查收" --sticky --send-ding dws chat group notice create --conversation-id --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "content"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "content": mustGetFlag(cmd, "content"), } if v, _ := cmd.Flags().GetBool("sticky"); v { toolArgs["sticky"] = true } if v, _ := cmd.Flags().GetBool("send-ding"); v { toolArgs["sendDing"] = true } if v, _ := cmd.Flags().GetString("run-at"); v != "" { toolArgs["scheduled"] = true toolArgs["runAtText"] = v } return callMCPToolOnServer("im", "create_group_notice", toolArgs) }, } chatGroupNoticeCreateCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupNoticeCreateCmd.MarkFlagRequired("conversation-id") chatGroupNoticeCreateCmd.Flags().String("content", "", "公告正文,Markdown 格式 (必填)") _ = chatGroupNoticeCreateCmd.MarkFlagRequired("content") chatGroupNoticeCreateCmd.Flags().Bool("sticky", false, "是否吊顶置顶(默认 false)") chatGroupNoticeCreateCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)") chatGroupNoticeCreateCmd.Flags().String("run-at", "", "定时发布时间 ISO-8601(如 2026-07-03T09:00:00+08:00,传入则定时发布)") DeclareLeafMetadata(chatGroupNoticeCreateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_group_notice", CanonicalPath: "chat." + "create_group_notice", CLIPath: "chat group notice create", PrimaryCLIPath: "chat group notice create", }, Description: "在指定群聊中发布群公告", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_group_notice"}, }, Selection: contract.SelectionSpec{ AgentSummary: "在指定群聊中发布群公告", UseWhen: []string{"需要在群聊里发布 Markdown 群公告,可选吊顶、DING 或定时发布时"}, AvoidWhen: []string{"修改已有群公告时使用 chat group notice edit"}, Examples: []string{"dws chat group notice create --conversation-id --content \"今晚维护\""}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "content", Property: "content", Required: boolPtr(true)}, {Name: "run-at", Property: "runAtText", Required: boolPtr(false)}, {Name: "send-ding", Property: "sendDing", Required: boolPtr(false)}, {Name: "sticky", Property: "sticky", Required: boolPtr(false)}, }, }, }) chatGroupNoticeEditCmd := &cobra.Command{ Use: "edit", Short: "修改群公告", Long: `修改指定群聊中的群公告,正文为 Markdown 格式,会整体替换原公告内容。`, Example: ` dws chat group notice edit --conversation-id --notice-id --content "更新后的公告内容" dws chat group notice edit --conversation-id --notice-id --content "更新后的公告内容" --sticky --send-ding # 查询公告 ID: dws chat group notice list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "notice-id", "content"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "dataId": mustGetFlag(cmd, "notice-id"), "content": mustGetFlag(cmd, "content"), } if v, _ := cmd.Flags().GetBool("sticky"); v { toolArgs["sticky"] = true } if v, _ := cmd.Flags().GetBool("send-ding"); v { toolArgs["sendDing"] = true } return callMCPToolOnServer("im", "edit_group_notice", toolArgs) }, } chatGroupNoticeEditCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupNoticeEditCmd.MarkFlagRequired("conversation-id") chatGroupNoticeEditCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") _ = chatGroupNoticeEditCmd.MarkFlagRequired("notice-id") chatGroupNoticeEditCmd.Flags().String("content", "", "公告新正文,Markdown 格式 (必填)") _ = chatGroupNoticeEditCmd.MarkFlagRequired("content") chatGroupNoticeEditCmd.Flags().Bool("sticky", false, "是否吊顶置顶(不传按 false 处理)") chatGroupNoticeEditCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)") DeclareLeafMetadata(chatGroupNoticeEditCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "edit_group_notice", CanonicalPath: "chat." + "edit_group_notice", CLIPath: "chat group notice edit", PrimaryCLIPath: "chat group notice edit", }, Description: "修改指定群聊中的群公告", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "edit_group_notice"}, }, Selection: contract.SelectionSpec{ AgentSummary: "修改指定群聊中的群公告", UseWhen: []string{"已有公告 dataId,需要替换群公告正文或调整吊顶/DING 状态时"}, AvoidWhen: []string{"发布新公告时使用 chat group notice create"}, Examples: []string{"dws chat group notice edit --conversation-id --notice-id --content \"更新后的公告\""}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "content", Property: "content", Required: boolPtr(true)}, {Name: "notice-id", Property: "dataId", Required: boolPtr(true)}, {Name: "send-ding", Property: "sendDing", Required: boolPtr(false)}, {Name: "sticky", Property: "sticky", Required: boolPtr(false)}, }, }, }) chatGroupNoticeGetCmd := &cobra.Command{ Use: "get", Short: "查看群公告详情", Long: `查看指定群公告的详情,包含正文摘要、吊顶状态、发布者、已读人数、点赞/评论数等信息。`, Example: ` dws chat group notice get --conversation-id --notice-id # 查询公告 ID: dws chat group notice list --conversation-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id", "notice-id"); err != nil { return err } return callMCPToolOnServer("im", "get_group_notice", map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), "dataId": mustGetFlag(cmd, "notice-id"), }) }, } chatGroupNoticeGetCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupNoticeGetCmd.MarkFlagRequired("conversation-id") chatGroupNoticeGetCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") _ = chatGroupNoticeGetCmd.MarkFlagRequired("notice-id") DeclareLeafMetadata(chatGroupNoticeGetCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "get_group_notice", CanonicalPath: "chat." + "get_group_notice", CLIPath: "chat group notice get", PrimaryCLIPath: "chat group notice get", }, Description: "查看指定群公告详情", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "get_group_notice"}, }, Selection: contract.SelectionSpec{ AgentSummary: "查看指定群公告详情", UseWhen: []string{"已有群公告 dataId,需要查看公告详情、状态或互动统计时"}, AvoidWhen: []string{"不知道公告 ID 时先使用 chat group notice list"}, Examples: []string{"dws chat group notice get --conversation-id --notice-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "notice-id", Property: "dataId", Required: boolPtr(true)}, }, }, }) chatGroupNoticeListCmd := &cobra.Command{ Use: "list", Short: "查看群公告列表", Long: `分页查看指定群聊的群公告列表。默认查询已发布公告,传 --scheduled 查询定时公告列表。 支持游标分页,hasMore=true 时用返回的 nextPageCursor 作为下次 --cursor。`, Example: ` dws chat group notice list --conversation-id dws chat group notice list --conversation-id --limit 20 --cursor dws chat group notice list --conversation-id --scheduled # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if v, _ := cmd.Flags().GetInt("limit"); v > 0 { toolArgs["limit"] = v } if v, _ := cmd.Flags().GetString("cursor"); v != "" { toolArgs["cursor"] = v } if v, _ := cmd.Flags().GetBool("scheduled"); v { toolArgs["scheduled"] = true } return callMCPToolOnServer("im", "list_group_notices", toolArgs) }, } chatGroupNoticeListCmd.Flags().String("conversation-id", "", "群聊 openConversationId (必填)") _ = chatGroupNoticeListCmd.MarkFlagRequired("conversation-id") chatGroupNoticeListCmd.Flags().Int("limit", 10, "每页返回数量(默认 10,最大 100)") chatGroupNoticeListCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextPageCursor)") chatGroupNoticeListCmd.Flags().Bool("scheduled", false, "是否查询定时公告列表(默认 false,查询已发布公告)") DeclareLeafMetadata(chatGroupNoticeListCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_group_notices", CanonicalPath: "chat." + "list_group_notices", CLIPath: "chat group notice list", PrimaryCLIPath: "chat group notice list", }, Description: "分页查看指定群聊的群公告列表", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_group_notices"}, }, Selection: contract.SelectionSpec{ AgentSummary: "分页查看指定群聊的群公告列表", UseWhen: []string{"需要列出群公告、查找公告 dataId 或翻页查看定时公告时"}, AvoidWhen: []string{"已知 dataId 并只看详情时使用 chat group notice get"}, Examples: []string{"dws chat group notice list --conversation-id --limit 20"}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "cursor", Property: "cursor", Required: boolPtr(false)}, {Name: "limit", Property: "limit", Required: boolPtr(false)}, {Name: "scheduled", Property: "scheduled", Required: boolPtr(false)}, }, }, }) chatGroupNoticeCmd.AddCommand(chatGroupNoticeCreateCmd, chatGroupNoticeEditCmd, chatGroupNoticeGetCmd, chatGroupNoticeListCmd) chatGroupShareInviteCmd := &cobra.Command{ Use: "share-invite", Short: "分享群聊链接到会话", Long: `将指定群的邀请链接分享到另一个会话或单聊用户。--target 和 --receiver 二选一:--target 指定目标会话,--receiver 指定单聊用户。`, Example: ` dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId> dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId> dws chat group share-invite --source --target --expires-seconds 86400 # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs, err := buildChatGroupShareInviteArgs(cmd) if err != nil { return err } return callMCPToolOnServer("im", "share_group_invite_url", toolArgs) }, } chatGroupShareInviteCmd.Flags().String("source", "", "被分享群的 openConversationId (必填)") _ = chatGroupShareInviteCmd.MarkFlagRequired("source") chatGroupShareInviteCmd.Flags().String("target", "", "接收分享消息的会话 openConversationId(与 --receiver 二选一)") chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)") chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值") chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)") chatGroupShareInviteCmd.Flags().BoolP("yes", "y", false, "确认分享群邀请链接") DeclareLeafMetadata(chatGroupShareInviteCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "share_group_invite_url", CanonicalPath: "chat.share_group_invite_url", CLIPath: "chat group share-invite", PrimaryCLIPath: "chat group share-invite", }, Description: "将指定群聊的邀请链接分享到会话或单聊用户", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "share_group_invite_url"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将指定群聊的邀请链接分享到会话或单聊用户", UseWhen: []string{"用户要求把某个群的邀请链接发送给另一个会话或指定单聊用户时"}, AvoidWhen: []string{"只是获取群邀请链接时使用 chat group invite-url"}, Examples: []string{"dws chat group share-invite --source --target "}, }, Parameters: []contract.ParamDecl{ {Name: "expires-seconds", Property: "expiresSeconds", Required: boolPtr(false), InterfaceType: "integer"}, {Name: "receiver", Property: "receiverOpenDingTalkId", Required: boolPtr(false)}, {Name: "source", Property: "sourceOpenConversationId", Required: boolPtr(true)}, {Name: "target", Property: "targetOpenConversationId", Required: boolPtr(false)}, {Name: "uuid", Property: "uuid", Required: boolPtr(false)}, }, }, }) chatGroupUpgradeToExternalCmd := &cobra.Command{ Use: "upgrade-to-external", Short: "[危险] 将普通群升级为外部群", Long: `[危险] 将已有普通群升级为外部群。适用于邀请外部联系人、开展跨组织协作,或保留原群会话并转换群类型的场景。 本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。 该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`, Example: ` dws chat group upgrade-to-external --conversation-id --dry-run dws chat group upgrade-to-external --conversation-id --extension '{"source":"dws"}' --dry-run # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), } if rawExtension := mustGetFlag(cmd, "extension"); rawExtension != "" { var rawValues map[string]any if err := json.Unmarshal([]byte(rawExtension), &rawValues); err != nil { return fmt.Errorf("--extension must be a JSON object with string values: %w", err) } if rawValues == nil { return fmt.Errorf("--extension must be a JSON object with string values") } extension := make(map[string]string, len(rawValues)) for key, value := range rawValues { stringValue, ok := value.(string) if !ok { return fmt.Errorf("--extension value for %q must be a string, got %T", key, value) } extension[key] = stringValue } toolArgs["extension"] = extension } return callMCPToolOnServer("im", "upgrade_group_to_external", toolArgs) }, } DeclareLeafMetadata(chatGroupUpgradeToExternalCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "upgrade_group_to_external", CanonicalPath: "chat.upgrade_group_to_external", CLIPath: "chat group upgrade-to-external", PrimaryCLIPath: "chat group upgrade-to-external", }, Description: "不可逆地把已有普通群升级为外部群", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: the executable CLI validates an optional string map and calls im/upgrade_group_to_external, which is absent from the pinned MCP metadata snapshot.", }, Selection: contract.SelectionSpec{ AgentSummary: "不可逆地把已有普通群升级为外部群", UseWhen: []string{"群主明确要求保留现有会话并升级为可跨组织协作的外部群,且已确认不可逆影响"}, AvoidWhen: []string{"新建外部群应使用 chat group create --type EXTERNAL;未确认群主身份和不可逆影响时不要执行"}, Examples: []string{"dws chat group upgrade-to-external --conversation-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)}, {Name: "extension", Property: "extension", Required: boolPtr(false), InterfaceType: "object"}, }, }, }) chatGroupUpgradeToExternalCmd.Flags().String("conversation-id", "", "待升级普通群的 openConversationId (必填)") _ = chatGroupUpgradeToExternalCmd.MarkFlagRequired("conversation-id") chatGroupUpgradeToExternalCmd.Flags().String("extension", "", `预留扩展字段 JSON 对象 (可选),如 '{"source":"dws"}'`) chatCategoryCreateSmartCmd := &cobra.Command{ Use: "create-smart", Short: "创建智能会话分组", Long: `创建智能会话分组,可指定群名称关键词和群内成员 openDingTalkId 作为匹配规则。`, Example: ` dws chat category create-smart --name "工作群" dws chat category create-smart --name "项目组" --keywords "项目,开发" dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2 dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`, RunE: func(cmd *cobra.Command, args []string) error { name := strings.TrimSpace(mustGetFlag(cmd, "name")) if name == "" { return apperrors.NewValidation("--name must not be blank") } toolArgs := map[string]any{ "categoryName": name, } if cmd.Flags().Changed("keywords") { values := parseCSVValues(mustGetFlag(cmd, "keywords")) if len(values) == 0 { return apperrors.NewValidation("--keywords must contain at least one non-empty value") } toolArgs["groupNameKeywords"] = values } if cmd.Flags().Changed("members") { values := parseCSVValues(mustGetFlag(cmd, "members")) if len(values) == 0 { return apperrors.NewValidation("--members must contain at least one non-empty value") } toolArgs["memberOpenDingTalkIds"] = values } return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs) }, } DeclareLeafMetadata(chatCategoryCreateSmartCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "create_smart_conv_category", CanonicalPath: "chat.create_smart_conv_category", CLIPath: "chat category create-smart", PrimaryCLIPath: "chat category create-smart", }, Description: "按名称、成员或关键词创建智能会话分组", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "按名称、成员或关键词创建智能会话分组", UseWhen: []string{"需要自动归集符合条件的会话时"}, AvoidWhen: []string{"只需查看现有分组或手工管理会话时不要使用"}, Examples: []string{"dws chat category create-smart --name \"项目群\" --keywords \"项目,交付\""}, }, Parameters: []contract.ParamDecl{ {Name: "keywords", Property: "groupNameKeywords", Required: boolPtr(false), InterfaceType: "array", Description: "群名称关键词列表,逗号分隔(可选)"}, {Name: "members", Property: "memberOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array", Description: "群内成员 openDingTalkId 列表,逗号分隔(可选)"}, {Name: "name", Property: "categoryName", Required: boolPtr(true), Description: "分组名称 (必填)"}, }, }, }) chatCategoryCreateSmartCmd.Flags().String("name", "", "分组名称 (必填)") _ = chatCategoryCreateSmartCmd.MarkFlagRequired("name") chatCategoryCreateSmartCmd.Flags().String("keywords", "", "群名称关键词列表,逗号分隔(可选)") chatCategoryCreateSmartCmd.Flags().String("members", "", "群内成员 openDingTalkId 列表,逗号分隔(可选)") chatMessageListEmotionRepliesCmd := &cobra.Command{ Use: "list-emotion-replies", Short: "批量拉取消息的表情回复和文字回复", Example: ` dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3 # 消息 ID 可通过 dws chat message list 获取`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "msg-ids"); err != nil { return err } msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids")) return callMCPToolOnServer("im", "list_message_emotion_replies", map[string]any{ "openMessageIds": msgIds, }) }, } chatMessageListEmotionRepliesCmd.Flags().String("msg-ids", "", "消息 ID 列表,逗号分隔 (必填)") _ = chatMessageListEmotionRepliesCmd.MarkFlagRequired("msg-ids") DeclareLeafMetadata(chatMessageListEmotionRepliesCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "list_message_emotion_replies", CanonicalPath: "chat." + "list_message_emotion_replies", CLIPath: "chat message list-emotion-replies", PrimaryCLIPath: "chat message list-emotion-replies", }, Description: "批量拉取消息的表情回复和文字回复", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_message_emotion_replies"}, }, Selection: contract.SelectionSpec{ AgentSummary: "批量拉取消息的表情回复和文字回复", UseWhen: []string{"已有一批消息 ID,需要查看对应 emoji reaction 或文字表情回复时"}, AvoidWhen: []string{"需要读取消息正文时使用 chat message list-by-ids 或 message list"}, Examples: []string{"dws chat message list-emotion-replies --msg-ids msgId1,msgId2"}, }, Parameters: []contract.ParamDecl{ {Name: "msg-ids", Property: "openMessageIds", Required: boolPtr(true), InterfaceType: "array"}, }, }, }) supportedTranslateLanguages := map[string]bool{ "en_US": true, "zh_CN": true, "zh_TW": true, "zh_HK": true, "ja_JP": true, "ko_KR": true, "vi_VN": true, "th_TH": true, "id_ID": true, "ms_MY": true, "es_419": true, "fr_FR": true, "pt_BR": true, "tr_TR": true, "ru_RU": true, "de_DE": true, "hi_IN": true, "hu_HU": true, "pl_PL": true, "sv_SE": true, "fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true, "he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true, } chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE} chatTextTranslateCmd := &cobra.Command{ Use: "translate", Short: "翻译文本内容", Long: `将指定文本翻译成目标语言。 支持的目标语言代码: en_US, zh_CN, zh_TW, zh_HK, ja_JP, ko_KR, vi_VN, th_TH, id_ID, ms_MY, es_419, fr_FR, pt_BR, tr_TR, ru_RU, de_DE, hi_IN, hu_HU, pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, Example: ` dws chat text translate --query "你好世界" --to en_US dws chat text translate --query "Hello World" --to zh_CN dws chat text translate --query "Bonjour" --to ja_JP`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "query", "to"); err != nil { return err } toLang := mustGetFlag(cmd, "to") if !supportedTranslateLanguages[toLang] { return fmt.Errorf("unsupported target language: %s", toLang) } return callMCPToolOnServer("im", "translate", map[string]any{ "query": mustGetFlag(cmd, "query"), "to": toLang, }) }, } chatTextTranslateCmd.Flags().String("query", "", "待翻译的文本内容 (必填)") _ = chatTextTranslateCmd.MarkFlagRequired("query") chatTextTranslateCmd.Flags().String("to", "en_US", "目标语言代码 (必填,默认 en_US)") _ = chatTextTranslateCmd.MarkFlagRequired("to") DeclareLeafMetadata(chatTextTranslateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "translate", CanonicalPath: "chat." + "translate", CLIPath: "chat text translate", PrimaryCLIPath: "chat text translate", }, Description: "将指定文本翻译成目标语言", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "translate"}, }, Selection: contract.SelectionSpec{ AgentSummary: "将指定文本翻译成目标语言", UseWhen: []string{"需要把一段文本翻译为指定目标语言代码时"}, AvoidWhen: []string{"需要发送翻译结果到聊天时先翻译再使用消息发送命令"}, Examples: []string{"dws chat text translate --query \"你好世界\" --to en_US"}, }, Parameters: []contract.ParamDecl{ {Name: "query", Property: "query", Required: boolPtr(true)}, {Name: "to", Property: "to", Required: boolPtr(true)}, }, }, }) chatTextCmd.AddCommand(chatTextTranslateCmd) chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd) // ── chat group user-settings ── chatGroupUserSettingsCmd := &cobra.Command{ Use: "user-settings", Short: "批量查询或更新当前用户的群会话设置", RunE: groupRunE, } chatGroupUserSettingsQueryCmd := &cobra.Command{ Use: "query", Short: "批量查询当前用户的群会话设置", Example: ` dws chat group user-settings query --groups cid1,cid2`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "groups"); err != nil { return err } convIds := parseCSVValues(mustGetFlag(cmd, "groups")) if len(convIds) == 0 { return fmt.Errorf("--groups must not be empty") } if len(convIds) > 100 { return fmt.Errorf("--groups batch size %d exceeds limit 100", len(convIds)) } return callMCPToolOnServer("im", "batch_query_group_chat_settings", map[string]any{ "openConversationIds": convIds, }) }, } DeclareLeafMetadata(chatGroupUserSettingsQueryCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "batch_query_group_chat_settings", CanonicalPath: "chat.batch_query_group_chat_settings", CLIPath: "chat group user-settings query", PrimaryCLIPath: "chat group user-settings query", }, Description: "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)", UseWhen: []string{"用户说 看下这些群我的置顶和免打扰设置"}, AvoidWhen: []string{"管理员级群功能开关用 chat group update-settings"}, Examples: []string{"dws chat group user-settings query --groups cid1,cid2 --format json"}, }, }, }) chatGroupUserSettingsQueryCmd.Flags().String("groups", "", "群会话 openConversationId 列表,逗号分隔,最多 100 个 (必填)") chatGroupUserSettingsSetCmd := &cobra.Command{ Use: "set", Short: "批量更新当前用户的群会话设置", Example: ` dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "items"); err != nil { return err } itemsJSON := mustGetFlag(cmd, "items") var items []map[string]any if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil { return fmt.Errorf("--items JSON parse error: %w", err) } if len(items) == 0 { return fmt.Errorf("--items must not be empty") } if len(items) > 100 { return fmt.Errorf("--items batch size %d exceeds limit 100", len(items)) } // 每项必须携带非空 openConversationId,缺失时 fail-fast,避免下发无效批量更新。 for i, item := range items { cid, _ := item["openConversationId"].(string) if strings.TrimSpace(cid) == "" { return fmt.Errorf("--items[%d] 缺少非空 openConversationId", i) } } return callMCPToolOnServer("im", "batch_update_group_chat_settings", map[string]any{ "items": items, }) }, } DeclareLeafMetadata(chatGroupUserSettingsSetCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "batch_update_group_chat_settings", CanonicalPath: "chat.batch_update_group_chat_settings", CLIPath: "chat group user-settings set", PrimaryCLIPath: "chat group user-settings set", }, Description: "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", }, Selection: contract.SelectionSpec{ AgentSummary: "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)", UseWhen: []string{"用户说 把这些群都设为免打扰/置顶"}, AvoidWhen: []string{"单个群昵称优先 chat group update-nick"}, Examples: []string{"dws chat group user-settings set --items '[{\"openConversationId\":\"cid1\",\"top\":true,\"mute\":false}]' --format json"}, }, }, }) chatGroupUserSettingsSetCmd.Flags().String("items", "", `JSON 数组,每项 {"openConversationId":"cid","top":bool,"mute":bool,"groupNick":"...","groupAlias":"..."} (必填)`) chatGroupUserSettingsCmd.AddCommand(chatGroupUserSettingsQueryCmd, chatGroupUserSettingsSetCmd) chatGroupCmd.AddCommand(chatGroupUserSettingsCmd) chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd) chatBotCmd.AddCommand(chatBotFindCmd) chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd) chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd) root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand()) // Keep the v1.0.56 command surface recognizable while directing callers to // the supported nested commands. The chat root's "im" alias makes these // compatibility hints available through both chat and im. root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send")) root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --conversation-id ")) installChatIMIDFlagAliases(root) return root }