Files
dingtalk-workspace-cli/internal/helpers/helpers.go
T

608 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// 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 <em></em> tags, keeping inner text.
func stripHTMLEm(s string) string {
s = strings.ReplaceAll(s, "<em>", "")
s = strings.ReplaceAll(s, "</em>", "")
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 _, ok := body["error"].(string); ok {
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
}
// 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
}
// 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)
}
}