package helpers
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Re-export cmdutil functions as package-level aliases so that existing product
// files continue to compile with their current (unexported) call sites.
// This avoids a mass-rename in 22 product files while still consolidating the
// implementations in pkg/cmdutil.
var (
groupRunE = cmdutil.GroupRunE
hintSubCmd = cmdutil.HintSubCmd
mustGetFlag = cmdutil.MustGetFlag
flagOrFallback = cmdutil.FlagOrFallback
mustFlagOrFallback = cmdutil.MustFlagOrFallback
validateRequiredFlags = cmdutil.ValidateRequiredFlags
validateRequiredFlagWithAliases = cmdutil.ValidateRequiredFlagWithAliases
parseISOTimeToMillis = cmdutil.ParseISOTimeToMillis
validateTimeRange = cmdutil.ValidateTimeRange
)
// Deps holds shared dependencies injected from the host application.
type Deps struct {
Caller edition.ToolCaller
Out *Formatter
}
// deps is the package-level dependency holder, set during registration.
var deps *Deps
// InitDeps initializes shared dependencies for all product commands.
// Must be called before any product command's RunE executes (typically
// during command tree construction in newLegacyPublicCommands).
func InitDeps(caller edition.ToolCaller) {
deps = &Deps{
Caller: caller,
Out: NewFormatter(),
}
}
// GetFormatter returns the shared output formatter for use by sibling packages.
func GetFormatter() *Formatter {
if deps == nil {
return NewFormatter()
}
return deps.Out
}
// copyFlags copies specified flags from source command to target command.
// This is useful when creating alias commands that reuse another command's RunE.
func copyFlags(src, dst *cobra.Command, flagNames ...string) {
for _, name := range flagNames {
if f := src.Flags().Lookup(name); f != nil {
dst.Flags().AddFlag(f)
}
}
}
// GetCaller returns the shared ToolCaller for use by sibling packages.
func GetCaller() edition.ToolCaller {
if deps == nil {
return nil
}
return deps.Caller
}
// cmdToProduct maps CLI command names to MCP server IDs for direct routing.
var cmdToProduct = map[string]string{
"aitable": "aitable", "calendar": "calendar", "contact": "contact",
"todo": "todo", "doc": "doc", "chat": "chat",
"oa": "oa", "mail": "mail", "ding": "ding",
"devdoc": "devdoc",
"attendance": "attendance",
"live": "live", "aiapp": "aiapp",
"minutes": "minutes",
"finance": "finance",
"report": "report",
"drive": "drive",
"blackboard": "blackboard",
"credit": "credit-ep",
"docparse": "docparse",
"aidesign": "aidesign",
"sheet": "sheet",
"wiki": "wiki",
"aisearch": "aisearch",
"yida": "yida",
// vendor extension command routing (kept here for resolveProductID)
"unified-toolkit": "unified-toolkit",
"outbound-call": "outbound-call",
"discovery": "discovery",
"ai-sincere-hire": "ai-sincere-hire",
"contract": "contract",
"oa-plus": "oa",
"pat": "pat",
"edu-contact": "edu-contact",
"edu-group": "edu-group",
"edu-app": "edu-app",
"agoal": "agoal",
}
// resolveProductID determines the MCP server ID from the CLI args.
// It scans os.Args for the first known product command name.
func resolveProductID() string {
for _, arg := range os.Args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
if pid, ok := cmdToProduct[arg]; ok {
return pid
}
}
return ""
}
// callMCPToolReturnText calls an MCP tool and returns the first text content,
// used when the caller needs to parse the result (e.g. extracting an eventId).
func callMCPToolReturnText(ctx context.Context, toolName string, args map[string]any) (string, error) {
serverID := resolveProductID()
if serverID == "" {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("cannot resolve product for tool %q", toolName),
}
}
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
}
func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
result, err := deps.Caller.CallTool(ctx, serverID, toolName, args)
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
return "", patErr
}
return "", WrapError(err)
}
for _, c := range result.Content {
if c.Type == "text" && c.Text != "" {
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return "", &CLIError{
Code: CodeAuthTokenExpired,
Message: c.Text,
Suggestion: authExpiredSuggestion(),
}
}
if isNotLoggedInError(errBody) {
return "", &CLIError{
Code: CodeAuthNotConfigured,
Message: "当前未登录",
Suggestion: notLoggedInSuggestion(),
}
}
if patErr := classifyPATError(errBody); patErr != nil {
return "", patErr
}
if isBusinessError(errBody) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: c.Text,
Suggestion: suggestForBusinessError(errBody),
}
}
}
return c.Text, nil
}
}
return "", nil
}
// CallMCPToolTextOnServer invokes an MCP tool and returns its raw text response
// WITHOUT printing anything, applying the same error classification as the
// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts,
// which chain several tool calls and need each intermediate result as data.
func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args)
}
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
func callMCPTool(toolName string, args map[string]any) error {
return callMCPToolInternalOpts("", toolName, args, false)
}
// callMCPToolUnescaped 与 callMCPTool 功能相同,但 JSON 输出禁用 HTML 转义。
// 适用于返回值中包含 URL(如 presignedUrl)的接口,避免 & 被转义为 \u0026。
// 当前仅 minutes upload 的三个命令使用此函数。
func callMCPToolUnescaped(toolName string, args map[string]any) error {
return callMCPToolInternalOpts("", toolName, args, true)
}
// callMCPToolOnServer 在指定的 MCP Server 上调用工具,跳过 resolveProductID() 的自动路由。
// 用于需要显式指定 serverID 的场景(如 credit 等多 server 产品)。
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolInternalOpts(serverID, toolName, args, false)
}
// CallMCPToolOnServer is the exported version of callMCPToolOnServer for use
// by extension packages that live in separate Go packages.
func CallMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolOnServer(serverID, toolName, args)
}
// GroupRunE is the exported version of groupRunE for use by extension packages.
func GroupRunE(cmd *cobra.Command, args []string) error {
return groupRunE(cmd, args)
}
// MustGetStringFlag retrieves a string flag, falling back to inherited flags.
func MustGetStringFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return val
}
// callMCPToolInternalOpts 是所有 MCP 工具调用的核心实现。
//
// 参数说明:
// - explicitServerID: 显式指定的 MCP Server ID,为空时自动路由
// - toolName: MCP 工具名称(如 "create_upload_session")
// - args: 工具调用参数,会被序列化为 JSON 传给 MCP Server
// - unescapeHTML: 是否禁用 JSON 输出的 HTML 转义(仅影响最终输出格式)
//
// 执行流程:
// 1. DryRun 模式:仅打印工具名和参数,不实际调用
// 2. 调用 MCP Server 获取结果
// 3. 错误分类:网关错误 → 未登录 → PAT 错误 → 业务错误
// 4. 根据 --format 标志选择输出格式(json / table / raw)
func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
ctx := context.Background()
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
if deps.Caller.DryRun() {
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
deps.Out.PrintKeyValue("Tool", toolName)
if args != nil {
argsJSON, _ := json.MarshalIndent(args, " ", " ")
deps.Out.PrintKeyValue("Arguments", "\n "+string(argsJSON))
}
return nil
}
// 确定目标 MCP Server:优先使用显式指定的 serverID,否则自动路由
serverID := explicitServerID
if serverID == "" {
serverID = resolveProductID()
}
// 调用 MCP Server
result, err := deps.Caller.CallTool(ctx, serverID, toolName, args)
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
return patErr
}
return WrapErrorWithOperation(err, serverID+"/"+toolName)
}
// 根据 unescapeHTML 选择 JSON 输出函数:
// - false(默认):使用 PrintJSON,& 会被转义为 \u0026
// - true:使用 PrintJSONUnescaped,保留原始字符(适用于含 URL 的返回值)
printJSON := deps.Out.PrintJSON
if unescapeHTML {
printJSON = deps.Out.PrintJSONUnescaped
}
flagFormat := deps.Caller.Format()
for _, c := range result.Content {
if c.Type == "text" {
// 尝试将返回文本解析为 JSON,进行错误分类
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
// 网关层错误(如 token 过期)
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
}
// 未登录错误
if isNotLoggedInError(errBody) {
return &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录", Suggestion: notLoggedInSuggestion()}
}
// PAT(个人访问令牌)相关错误
if patErr := classifyPATError(errBody); patErr != nil {
return patErr
}
// 业务逻辑错误
if isBusinessError(errBody) {
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
}
}
// JSON 格式输出:解析后使用选定的 printJSON 函数输出
if flagFormat == "json" {
var parsed any
if err := json.Unmarshal([]byte(c.Text), &parsed); err == nil {
return printJSON(parsed)
}
}
// 特殊处理:开放平台文档搜索结果的表格格式输出
if toolName == "search_open_platform_docs" && flagFormat == "table" {
if formatted := formatDevdocSearchTable(c.Text); formatted {
return nil
}
}
// 默认:原样输出文本内容。
// 当 unescapeHTML=true 时,c.Text 是一段 JSON 字符串,其中 & 已被服务端
// 的 JSON 编码器转义为 \u0026。此处先 Unmarshal 还原为 Go 对象,再用
// PrintJSONUnescaped 输出,保证 & 不被二次转义。
if unescapeHTML {
var parsed any
if err := json.Unmarshal([]byte(c.Text), &parsed); err == nil {
return printJSON(parsed)
}
}
deps.Out.PrintRaw(c.Text)
return nil
}
}
// 无 text 类型内容时,将整个 result 对象序列化为 JSON 输出
return printJSON(result)
}
// formatDevdocSearchTable formats devdoc search JSON results as a table.
// Returns true on success, false if the JSON cannot be parsed.
func formatDevdocSearchTable(raw string) bool {
var resp struct {
Result struct {
Items []struct{ Title, URL string }
CurrentPage int `json:"currentPage"`
TotalCount int `json:"totalCount"`
HasMore bool `json:"hasMore"`
}
}
if json.Unmarshal([]byte(raw), &resp) != nil {
return false
}
items := resp.Result.Items
if len(items) == 0 {
deps.Out.PrintInfo("no matching documents")
return true
}
headers := []string{"标题", "URL"}
rows := make([][]string, len(items))
for i, it := range items {
title := stripHTMLEm(it.Title)
rows[i] = []string{title, it.URL}
}
deps.Out.PrintTable(headers, rows)
pageInfo := fmt.Sprintf("page %d, total %d", resp.Result.CurrentPage, resp.Result.TotalCount)
if resp.Result.HasMore {
pageInfo += ", use --page " + fmt.Sprintf("%d", resp.Result.CurrentPage+1) + " for more"
}
deps.Out.PrintDim(pageInfo)
return true
}
// stripHTMLEm removes tags, keeping inner text.
func stripHTMLEm(s string) string {
s = strings.ReplaceAll(s, "", "")
s = strings.ReplaceAll(s, "", "")
return s
}
// getCurrentUserID fetches the current user's userId via the contact MCP server.
func getCurrentUserID(ctx context.Context) (string, error) {
result, err := deps.Caller.CallTool(ctx, "contact", "get_current_user_profile", nil)
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
for _, c := range result.Content {
if c.Type != "text" || c.Text == "" {
continue
}
var data struct {
Result []struct {
OrgEmployeeModel struct {
UserID string `json:"userId"`
} `json:"orgEmployeeModel"`
} `json:"result"`
}
if json.Unmarshal([]byte(c.Text), &data) == nil && len(data.Result) > 0 && data.Result[0].OrgEmployeeModel.UserID != "" {
return data.Result[0].OrgEmployeeModel.UserID, nil
}
var data2 struct {
Result struct {
UserID string `json:"userId"`
} `json:"result"`
}
if json.Unmarshal([]byte(c.Text), &data2) == nil && data2.Result.UserID != "" {
return data2.Result.UserID, nil
}
var flat map[string]any
if json.Unmarshal([]byte(c.Text), &flat) == nil {
if arr, ok := flat["result"].([]any); ok && len(arr) > 0 {
if m, ok := arr[0].(map[string]any); ok {
if oem, ok := m["orgEmployeeModel"].(map[string]any); ok {
if uid, ok := oem["userId"].(string); ok && uid != "" {
return uid, nil
}
}
}
}
if r, ok := flat["result"].(map[string]any); ok {
if uid, ok := r["userId"].(string); ok && uid != "" {
return uid, nil
}
}
}
}
return "", fmt.Errorf("cannot parse userId from get_current_user_profile response")
}
// classifyPATError checks if a parsed JSON body contains a PAT permission error code.
// Returns a *PATError if matched, nil otherwise.
func classifyPATError(body map[string]any) error {
for _, key := range []string{"code", "errorCode"} {
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
}
return nil
}
// reclassifyPATFromError inspects an error returned by the framework (which may
// have classified a PAT response as a generic business error) and converts it
// to a *PATError if the error message contains a known PAT permission code.
func reclassifyPATFromError(err error) error {
if _, ok := err.(*PATError); ok {
return err
}
msg := err.Error()
for code := range patNoPermissionCodes {
if strings.Contains(msg, code) {
return &PATError{RawJSON: buildMinimalPATJSON(code)}
}
}
return nil
}
func buildMinimalPATJSON(code string) string {
out := map[string]any{"success": false, "code": code}
b, _ := json.MarshalIndent(out, "", " ")
return string(b)
}
// isBusinessError checks if a parsed JSON body represents a business-level error.
func isBusinessError(body map[string]any) bool {
if v, ok := body["error"]; ok {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return true
}
case map[string]any:
if len(t) > 0 {
return true
}
case []any:
if len(t) > 0 {
return true
}
default:
if t != nil {
return true
}
}
}
if v, ok := body["status"].(string); ok && strings.EqualFold(strings.TrimSpace(v), "error") {
return true
}
for _, key := range []string{"errorCode", "error_code", "code"} {
if isErrorCodeValue(body[key]) {
return true
}
}
if v, ok := body["success"].(bool); ok && !v {
return true
}
if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") {
return true
}
return false
}
func isErrorCodeValue(v any) bool {
switch t := v.(type) {
case string:
code := strings.TrimSpace(t)
if code == "" {
return false
}
switch strings.ToLower(code) {
case "0", "ok", "success", "succeed":
return false
default:
return true
}
case float64:
return t != 0
case int:
return t != 0
case int64:
return t != 0
case json.Number:
return strings.TrimSpace(t.String()) != "" && t.String() != "0"
default:
return false
}
}
// isNotLoggedInError checks if the error body indicates missing authentication.
func isNotLoggedInError(body map[string]any) bool {
if errMsg, ok := body["error"].(string); ok {
if strings.Contains(errMsg, "Missing service_id or access_key") {
return true
}
}
return false
}
// notLoggedInSuggestion returns a mode-aware hint for not-logged-in errors.
func notLoggedInSuggestion() string {
if edition.Get().IsEmbedded {
return "请先登录"
}
return "请先登录:dws auth login"
}
// authExpiredSuggestion returns a mode-aware hint for auth errors.
func authExpiredSuggestion() string {
if edition.Get().IsEmbedded {
return "Auth expired, re-run previous command (max 2 retries)"
}
return "Re-authenticate: dws auth login"
}
// dwsGatewayErrors is the set of DWS gateway-level auth error codes that
// must be surfaced as CodeAuthTokenExpired so the runner / overlay knows the
// current bearer token has been rejected.
//
// TOKEN_VERIFIED_FAILED / USER_TOKEN_ILLEGAL come from upstream DingTalk
// services that pass through the gateway; they are server-side rejections
// of an otherwise locally-valid token.
var dwsGatewayErrors = map[string]bool{
"DWS_SERVICE_UNAUTHORIZED": true,
"DWS_AUTH_SERVICE_FAILED": true,
"TOKEN_VERIFIED_FAILED": true,
"USER_TOKEN_ILLEGAL": true,
}
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
//
// Field-name coverage: the gateway and upstream services are inconsistent
// about which key carries the code — we have observed all of "errorCode",
// "error_code" and "code" in the wild — so check all three. The transport
// layer's ExtractServerDiagnosticsFromMap normalises into ServerErrorCode,
// but ClassifyToolResultContent is fed the raw content map and must do its
// own lookup.
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
for _, key := range []string{"errorCode", "error_code", "code"} {
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
return code, true
}
}
return "", false
}
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return suggestForBusinessErrorText(msg)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
// checks os.Args for --yes/-y (for callers that don't pass *cobra.Command).
func confirmDelete(resourceType, resourceName string) bool {
for _, arg := range os.Args[1:] {
if arg == "--yes" || arg == "-y" {
return true
}
}
warning := color.New(color.FgRed, color.Bold)
warning.Printf("About to delete %s: %s\n", resourceType, resourceName)
fmt.Print("Confirm deletion? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
deps.Out.PrintInfo("Operation cancelled")
return false
}
// confirmDangerousAction confirms a high-impact action whose semantics are
// not deletion. Keeping this separate from confirmDelete prevents enable,
// disable, or publication operations from being described as deletes.
func confirmDangerousAction(cmd *cobra.Command, action, resourceName string) bool {
if cmd == nil {
return false
}
if yes, err := cmd.Flags().GetBool("yes"); err == nil && yes {
return true
}
output := cmd.ErrOrStderr()
fmt.Fprintf(output, "About to %s: %s\n", action, resourceName)
fmt.Fprint(output, "Confirm action? (yes/no): ")
reader := bufio.NewReader(cmd.InOrStdin())
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
fmt.Fprintln(output, "Operation cancelled")
return false
}
// toCamelCase converts a kebab-case string to camelCase.
// Examples: "base-id" -> "baseId", "open-dingtalk-id" -> "openDingtalkId"
func toCamelCase(kebab string) string {
parts := strings.Split(kebab, "-")
if len(parts) <= 1 {
return kebab
}
for i := 1; i < len(parts); i++ {
if len(parts[i]) > 0 {
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
}
return strings.Join(parts, "")
}
// RegisterCamelCaseAliases recursively walks the command tree and registers
// hidden camelCase aliases for every kebab-case flag. This prevents flag-value
// prefix matching from misinterpreting AI-generated camelCase flags
// (e.g. --baseId) as a short flag + glued value (--base + "Id").
func RegisterCamelCaseAliases(cmd *cobra.Command) {
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !strings.Contains(f.Name, "-") {
return
}
camel := toCamelCase(f.Name)
if camel == f.Name || cmd.Flags().Lookup(camel) != nil {
return
}
switch f.Value.Type() {
case "int", "int64":
cmd.Flags().Int(camel, 0, "")
case "float64":
cmd.Flags().Float64(camel, 0, "")
case "bool":
cmd.Flags().Bool(camel, false, "")
case "stringSlice":
cmd.Flags().StringSlice(camel, nil, "")
default:
cmd.Flags().String(camel, "", "")
}
_ = cmd.Flags().MarkHidden(camel)
})
for _, child := range cmd.Commands() {
RegisterCamelCaseAliases(child)
}
}