diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index c19abb5e..e5ca1a83 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1 +1 @@ -coverage: 50.0%coverage50.0% \ No newline at end of file +coverage: 53.9%coverage53.9% \ No newline at end of file diff --git a/.gitignore b/.gitignore index 13741b1c..c4615d59 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ test/cli_compat/testdata/ .env.* *.pem *.key -credentials* \ No newline at end of file +credentials* +plans +_docs diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index c87320ef..d42d5e25 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -14,13 +14,15 @@ package helpers import ( + "fmt" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" - + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" "github.com/spf13/cobra" ) @@ -41,7 +43,8 @@ func (todoHandler) Name() string { func (todoHandler) Command(runner executor.Runner) *cobra.Command { root := &cobra.Command{ Use: "todo", - Short: "Todo helper overrides", + Short: i18n.T("待办任务管理"), + Long: i18n.T("管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。"), Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, @@ -60,11 +63,94 @@ func (todoHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - task.AddCommand(newTodoTaskListCommand(runner)) + task.AddCommand( + newTodoTaskCreateCommand(runner), + newTodoTaskListCommand(runner), + newTodoTaskUpdateCommand(runner), + newTodoTaskDoneCommand(runner), + newTodoTaskGetCommand(runner), + newTodoTaskDeleteCommand(runner), + ) root.AddCommand(task) return root } +// ── create ───────────────────────────────────────────────── + +func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: i18n.T("创建待办"), + Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40 + dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00" + + # 查询 userId: dws contact user search --keyword "姓名"`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + title := flagOrFallback(cmd, "title", "subject", "content") + if strings.TrimSpace(title) == "" { + return apperrors.NewValidation("--title is required") + } + executorsStr, _ := cmd.Flags().GetString("executors") + if strings.TrimSpace(executorsStr) == "" { + return apperrors.NewValidation("--executors is required") + } + executorIds := parseExecutorIds(executorsStr) + + vo := map[string]any{ + "subject": title, + "executorIds": executorIds, + } + if v, _ := cmd.Flags().GetString("due"); v != "" { + ms, err := parseISOTimeToMillis("due", v) + if err != nil { + return err + } + vo["dueTime"] = ms + } + if v, _ := cmd.Flags().GetString("priority"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + vo["priority"] = n + } + } + if v, _ := cmd.Flags().GetString("recurrence"); v != "" { + vo["recurrence"] = v + } + params := map[string]any{"PersonalTodoCreateVO": vo} + + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "todo", + "create_personal_todo", + params, + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return writeCommandPayload(cmd, result) + }, + } + preferLegacyLeaf(cmd) + + cmd.Flags().String("title", "", i18n.T("待办标题 (必填)")) + cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)")) + cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")) + cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急")) + cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1")) + + cmd.Flags().String("subject", "", i18n.T("--title 的别名")) + cmd.Flags().String("content", "", i18n.T("--title 的别名")) + _ = cmd.Flags().MarkHidden("subject") + _ = cmd.Flags().MarkHidden("content") + + return cmd +} + +// ── list ─────────────────────────────────────────────────── + func newTodoTaskListCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "list", @@ -159,6 +245,252 @@ func newTodoTaskListCommand(runner executor.Runner) *cobra.Command { return cmd } +// ── update ───────────────────────────────────────────────── + +func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: i18n.T("修改待办任务"), + Example: ` dws todo task update --task-id --title "新标题" + dws todo task update --task-id --priority 40 --due "2026-03-10T18:00:00+08:00" + dws todo task update --task-id --done true + + # 查询 taskId: dws todo task list`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + taskID, _ := cmd.Flags().GetString("task-id") + if strings.TrimSpace(taskID) == "" { + return apperrors.NewValidation("--task-id is required") + } + inner := map[string]any{ + "taskId": taskID, + } + if v, _ := cmd.Flags().GetString("title"); v != "" { + inner["subject"] = v + } + if v, _ := cmd.Flags().GetString("due"); v != "" { + ms, err := parseISOTimeToMillis("due", v) + if err != nil { + return err + } + inner["dueTime"] = ms + } + if v, _ := cmd.Flags().GetString("priority"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + inner["priority"] = n + } + } + if v, _ := cmd.Flags().GetString("done"); v != "" { + inner["isDone"] = v == "true" + } + params := map[string]any{"TodoUpdateRequest": inner} + + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "todo", + "update_todo_task", + params, + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return writeCommandPayload(cmd, result) + }, + } + preferLegacyLeaf(cmd) + + cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)")) + cmd.Flags().String("title", "", i18n.T("新标题")) + cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")) + cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急")) + cmd.Flags().String("done", "", i18n.T("完成状态: true/false")) + return cmd +} + +// ── done ─────────────────────────────────────────────────── + +func newTodoTaskDoneCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "done", + Short: i18n.T("修改执行者的待办完成状态"), + Example: ` dws todo task done --task-id --status true + dws todo task done --task-id --status false + + # 查询 taskId: dws todo task list`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + taskID, _ := cmd.Flags().GetString("task-id") + if strings.TrimSpace(taskID) == "" { + return apperrors.NewValidation("--task-id is required") + } + status, _ := cmd.Flags().GetString("status") + if strings.TrimSpace(status) == "" { + return apperrors.NewValidation("--status is required") + } + params := map[string]any{ + "taskId": taskID, + "isDone": status, + } + + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "todo", + "update_todo_done_status", + params, + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return writeCommandPayload(cmd, result) + }, + } + preferLegacyLeaf(cmd) + + cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)")) + cmd.Flags().String("status", "", i18n.T("完成状态: true=已完成, false=未完成 (必填)")) + return cmd +} + +// ── get ──────────────────────────────────────────────────── + +func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: i18n.T("待办详情"), + Example: ` dws todo task get --task-id + + # 查询 taskId: dws todo task list`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + taskID, _ := cmd.Flags().GetString("task-id") + if strings.TrimSpace(taskID) == "" { + return apperrors.NewValidation("--task-id is required") + } + params := map[string]any{ + "taskId": taskID, + } + + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "todo", + "query_todo_detail", + params, + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return writeCommandPayload(cmd, result) + }, + } + preferLegacyLeaf(cmd) + + cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)")) + return cmd +} + +// ── delete ───────────────────────────────────────────────── + +func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: i18n.T("删除待办"), + Example: ` dws todo task delete --task-id + dws todo task delete --task-id --yes + + # 查询 taskId: dws todo task list`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + taskID, _ := cmd.Flags().GetString("task-id") + if strings.TrimSpace(taskID) == "" { + return apperrors.NewValidation("--task-id is required") + } + if !confirmDeletePrompt(cmd, i18n.T("待办"), taskID) { + return nil + } + params := map[string]any{ + "taskId": taskID, + } + + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "todo", + "delete_todo", + params, + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return writeCommandPayload(cmd, result) + }, + } + preferLegacyLeaf(cmd) + + cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)")) + cmd.Flags().Bool("yes", false, i18n.T("跳过确认直接删除")) + return cmd +} + +// ── helpers ──────────────────────────────────────────────── + +// flagOrFallback returns the first non-empty value among the given flag names. +func flagOrFallback(cmd *cobra.Command, names ...string) string { + for _, name := range names { + if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array. +func parseExecutorIds(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + ids := make([]string, 0, len(parts)) + for _, p := range parts { + if id := strings.TrimSpace(p); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix +// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix. +func parseISOTimeToMillis(flagName, value string) (int64, error) { + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + } + for _, layout := range formats { + if t, err := time.Parse(layout, value); err == nil { + return t.UnixMilli(), nil + } + } + return 0, apperrors.NewValidation( + fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName), + ) +} + +// ── list pagination helpers ──────────────────────────────── + func normalizePage(raw string) string { if trimmed := strings.TrimSpace(raw); trimmed != "" { return trimmed diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 91b1f834..916f03ce 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -9,49 +9,51 @@ "AI诚聘": "AI Recruitment", "Base ID (必填)": "Base ID (required)", "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.", - "OSS 上传失败 HTTP %d: %s": "OSS uploadfailed HTTP %d: %s", + "OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s", "[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)", "prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format", - "refresh_token 刷新失败": "refresh_token refreshfailed", + "refresh_token 刷新失败": "refresh_token refresh failed", "refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login", - "true=已完成, false=未完成": "true=alreadycompleted, false=un-completed", + "true=已完成, false=未完成": "true=completed, false=not completed", "⏳ 等待授权中...": "⏳ Waiting for authorization...", "⚠️ 即将删除 %s: %s\\\n": "⚠️ About to delete %s: %s\\\n", - "上传失败: %w": "uploadfailed: %w", + "上传失败: %w": "upload failed: %w", "上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "Upstream service error; retry later. If persistent, check recovery snapshot.", - "下载失败 (HTTP %d): %s": "downloadfailed (HTTP %d): %s", + "下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s", "下载失败: %w": "Download failed: %w", - "不是文件: ": "notisfile:", + "不是文件: ": "not a file: ", "人才查询": "Talent query", "使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...", "保存 token 失败": "Failed to save token", - "保存Keyword subscription规则": "saveKeyword subscriptionrule", - "写入文件失败: %w": "writefilefailed: %w", - "准备上传失败: %w": "prepareuploadfailed: %w", - "分页查询审批实例": "paginated queryapproval instance", - "创建 / 查询 / 更新 / 删除待办": "create / query / update / deletetodo", - "创建文件失败: %w": "createfilefailed: %w", + "保存Keyword subscription规则": "Save keyword subscription rule", + "写入文件失败: %w": "write file failed: %w", + "准备上传失败: %w": "prepare upload failed: %w", + "分页查询审批实例": "Paginated query approval instances", + "创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo", + "创建待办": "Create todo", + "创建文件失败: %w": "create file failed: %w", "创建请求失败": "Failed to create request", - "删除 AI 表格": "delete AI table", - "删除原因(可选)": "deletereason(optional)", - "删除字段": "deletefield", + "删除待办": "Delete todo", + "删除 AI 表格": "Delete AI table", + "删除原因(可选)": "Delete reason (optional)", + "删除字段": "Delete field", "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.", "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified field (high risk, irreversible). Use --yes to skip confirmation.", "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (high risk, irreversible). Use --yes to skip confirmation.", - "删除数据表": "deletedata table", + "删除数据表": "Delete data table", "删除行记录": "Delete row records", "参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.", "发送请求失败": "Failed to send request", - "回调中未收到授权码": "callback un-receivetoauthorization code", + "回调中未收到授权码": "Callback did not receive authorization code", "如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n", "字段": "field", "字段 ID (必填)": "field ID (required)", - "字段管理": "fieldmanagement", - "审批实例管理": "approval instancemanagement", + "字段管理": "Field management", + "审批实例管理": "Approval instance management", "岗位查询": "Position query", "工具协议不兼容;请检查服务版本、工具名或刷新发现缓存。": "Tool protocol incompatible; check service version, tool name, or refresh discovery cache.", "工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.", - "已取消操作": "alreadycanceloperations", + "已取消操作": "Operation cancelled", "当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s", "或者直接打开以下链接:": "Or open the following link:", "所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate", @@ -65,9 +67,9 @@ "授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry", "数据表": "data table", "数据表 ID (必填)": "data table ID (required)", - "数据表管理": "data tablemanagement", - "文件不存在: ": "filenot found: ", - "文件为空": "fileis empty", + "数据表管理": "Data table management", + "文件不存在: ": "File not found: ", + "文件为空": "File is empty", "文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)", "无法打开文件: %w": "Cannot open file: %w", "无法自动打开浏览器": "Cannot automatically open browser", @@ -82,7 +84,7 @@ "服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "Service returned empty results; retry later. Check recovery snapshot if persistent.", "未找到 MCP Server URL": "MCP Server URL not found", "未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login", - "未登录,请运行 dws auth login": "not logged in,please run dws auth login", + "未登录,请运行 dws auth login": "Not logged in, please run dws auth login", "未知错误: %s": "Unknown error: %s", "本地文件一键上传到 AITable 附件字段": "One-click upload local file to AITable attachment field", "本地文件路径 (必填)": "Local file path (required)", @@ -90,36 +92,51 @@ "构建上传请求失败: %w": "Failed to build upload request: %w", "查询入职人才": "Query onboarding candidates", "查询在招岗位": "Query open positions", + "修改待办任务": "Update todo task", + "修改执行者的待办完成状态": "Update executor todo done status", + "优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent", "查询待办列表": "List todos", + "待办": "todo", + "待办任务 ID (必填)": "Todo task ID (required)", + "待办任务管理": "Todo task management", + "待办标题 (必填)": "Todo title (required)", + "待办详情": "Todo detail", + "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1", + "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)", + "执行者 userId 列表 (必填)": "Executor userId list (required)", "检查登录状态后重试": "Check login status and retry", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.", "检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry", "检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry", + "新标题": "New title", + "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.", "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "Step 1/3: Preparing upload %s (%d bytes, %s)...\\\n", "步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...", "步骤 3/3: 上传完成!": "Step 3/3: Upload complete!", "用户拒绝了授权请求": "User rejected the authorization request", - "确认删除? (yes/no): ": "confirmdelete? (yes/no): ", + "完成状态: true/false": "Done status: true/false", + "完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)", + "确认删除? (yes/no): ": "Confirm delete? (yes/no): ", "等待用户授权...": "Waiting for user authorization...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.", "网络错误,继续重试...": "Network error, retrying...", "获取使用指引": "Get usage guide", - "获取审批实例详情": "getapproval instancedetails", + "获取审批实例详情": "Get approval instance details", "获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)", - "获取版本信息失败 (HTTP %d)": "getversioninfofailed (HTTP %d)", + "获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)", "解析令牌数据失败": "Failed to parse token data", "解析响应失败": "Failed to parse response", - "解析版本信息失败: %w": "parseversioninfofailed: %w", + "解析版本信息失败: %w": "Parse version info failed: %w", "解析设备授权数据失败": "Failed to parse device authorization data", "认证信息已失效,请重新执行上一条命令(最多重试两次)": "Credentials expired, re-execute the last command (max 2 retries)", "认证失败;请检查登录状态或产品 URL 覆盖。": "Authentication failed; check login status or product URL override.", "记录": "record", - "记录 ID 列表,逗号分隔 (必填)": "record ID list,comma-separated (required)", - "记录管理": "recordmanagement", + "记录 ID 列表,逗号分隔 (必填)": "Record ID list, comma-separated (required)", + "记录管理": "Record management", "设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)", "设备授权码已过期": "Device authorization code has expired", "设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry", - "设置权限失败: %w": "settingspermissionfailed: %w", + "设置权限失败: %w": "Set permission failed: %w", "请在浏览器中完成扫码授权。": "Please complete QR code authorization in the browser.", "请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:", "请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.", @@ -130,11 +147,13 @@ "请求设备授权码失败": "Failed to request device authorization code", "读取 zip 条目失败: %w": "Failed to read zip entry: %w", "读取响应失败": "Failed to read response", - "读取版本信息失败: %w": "readversioninfofailed: %w", + "读取版本信息失败: %w": "Read version info failed: %w", + "跳过确认直接删除": "Skip confirmation and delete directly", + "--title 的别名": "Alias for --title", "调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.", "轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds", "返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken", - "附件工作流": "attachmentworkflow", + "附件工作流": "Attachment workflow", "页码 (必填)": "page number (required)", "🔐 登录钉钉": "🔐 Login to DingTalk" } diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 85f00a5a..2a029280 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -30,8 +30,10 @@ "准备上传失败: %w": "准备上传失败: %w", "分页查询审批实例": "分页查询审批实例", "创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办", + "创建待办": "创建待办", "创建文件失败: %w": "创建文件失败: %w", "创建请求失败": "创建请求失败", + "删除待办": "删除待办", "删除 AI 表格": "删除 AI 表格", "删除原因(可选)": "删除原因(可选)", "删除字段": "删除字段", @@ -90,15 +92,30 @@ "构建上传请求失败: %w": "构建上传请求失败: %w", "查询入职人才": "查询入职人才", "查询在招岗位": "查询在招岗位", + "修改待办任务": "修改待办任务", + "修改执行者的待办完成状态": "修改执行者的待办完成状态", + "优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急", "查询待办列表": "查询待办列表", + "待办": "待办", + "待办任务 ID (必填)": "待办任务 ID (必填)", + "待办任务管理": "待办任务管理", + "待办标题 (必填)": "待办标题 (必填)", + "待办详情": "待办详情", + "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1", + "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)", + "执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)", "检查登录状态后重试": "检查登录状态后重试", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。", "检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试", "检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令", + "新标题": "新标题", + "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。", "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n", "步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...", "步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!", "用户拒绝了授权请求": "用户拒绝了授权请求", + "完成状态: true/false": "完成状态: true/false", + "完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)", "确认删除? (yes/no): ": "确认删除? (yes/no): ", "等待用户授权...": "等待用户授权...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。", @@ -131,6 +148,8 @@ "读取 zip 条目失败: %w": "读取 zip 条目失败: %w", "读取响应失败": "读取响应失败", "读取版本信息失败: %w": "读取版本信息失败: %w", + "跳过确认直接删除": "跳过确认直接删除", + "--title 的别名": "--title 的别名", "调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。", "轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds", "返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken", diff --git a/test/cli_compat/todo_test.go b/test/cli_compat/todo_test.go index 4832800c..b0fc9c0f 100644 --- a/test/cli_compat/todo_test.go +++ b/test/cli_compat/todo_test.go @@ -6,12 +6,316 @@ import ( // ── task create ───────────────────────────────────────────── -func TestTodoTaskCreate_should_call_create_todo_task(t *testing.T) { +func TestTodoTaskCreate_should_call_create_personal_todo(t *testing.T) { cap := setupTestDeps(t, "todo") root := buildRoot() - err := execCmd(t, root, []string{"todo", "task", "create"}, nil) + err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "title": "修复线上Bug", + "executors": "userId1,userId2", + }) if err != nil { t.Fatalf("unexpected error: %v", err) } - assertToolName(t, cap, "create_todo_task") + assertToolName(t, cap, "create_personal_todo") +} + +func TestTodoTaskCreate_should_pass_subject_and_executorIds(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "title": "提交报告", + "executors": "userId1", + }) + assertToolArg(t, cap, "PersonalTodoCreateVO", map[string]any{ + "subject": "提交报告", + "executorIds": []any{"userId1"}, + }) +} + +func TestTodoTaskCreate_should_pass_priority(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "title": "紧急任务", + "executors": "userId1", + "priority": "40", + }) + last := cap.last() + if last == nil { + t.Fatal("no MCP call captured") + } + vo, ok := last.Args["PersonalTodoCreateVO"].(map[string]any) + if !ok { + t.Fatalf("expected PersonalTodoCreateVO map, got %T", last.Args["PersonalTodoCreateVO"]) + } + if vo["priority"] != float64(40) { + t.Errorf("expected priority=40, got %v", vo["priority"]) + } +} + +func TestTodoTaskCreate_should_error_when_title_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "executors": "userId1", + }) + if err == nil { + t.Fatal("expected error when --title is missing") + } +} + +func TestTodoTaskCreate_should_error_when_executors_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "title": "测试待办", + }) + if err == nil { + t.Fatal("expected error when --executors is missing") + } +} + +func TestTodoTaskCreate_should_accept_subject_alias(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "subject": "通过别名创建", + "executors": "userId1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "create_personal_todo") +} + +func TestTodoTaskCreate_should_not_call_mcp_in_dry_run(t *testing.T) { + cap := setupTestDepsWithDryRun(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{ + "title": "Dry Run", + "executors": "userId1", + }) + assertCallCount(t, cap, 0) +} + +// ── task list ─────────────────────────────────────────────── + +func TestTodoTaskList_should_call_get_user_todos(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "list"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "get_user_todos_in_current_org") +} + +func TestTodoTaskList_should_pass_status_filter(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "list"}, map[string]string{ + "status": "false", + }) + assertToolArg(t, cap, "isDone", "false") + assertToolArg(t, cap, "todoStatus", "false") +} + +// ── task update ───────────────────────────────────────────── + +func TestTodoTaskUpdate_should_call_update_todo_task(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{ + "task-id": "TASK_001", + "title": "新标题", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "update_todo_task") +} + +func TestTodoTaskUpdate_should_pass_TodoUpdateRequest(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{ + "task-id": "TASK_001", + "title": "更新标题", + }) + last := cap.last() + if last == nil { + t.Fatal("no MCP call captured") + } + req, ok := last.Args["TodoUpdateRequest"].(map[string]any) + if !ok { + t.Fatalf("expected TodoUpdateRequest map, got %T", last.Args["TodoUpdateRequest"]) + } + if req["taskId"] != "TASK_001" { + t.Errorf("expected taskId=TASK_001, got %v", req["taskId"]) + } + if req["subject"] != "更新标题" { + t.Errorf("expected subject=更新标题, got %v", req["subject"]) + } +} + +func TestTodoTaskUpdate_should_pass_done_flag(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{ + "task-id": "TASK_001", + "done": "true", + }) + last := cap.last() + if last == nil { + t.Fatal("no MCP call captured") + } + req, ok := last.Args["TodoUpdateRequest"].(map[string]any) + if !ok { + t.Fatalf("expected TodoUpdateRequest map, got %T", last.Args["TodoUpdateRequest"]) + } + if req["isDone"] != true { + t.Errorf("expected isDone=true, got %v", req["isDone"]) + } +} + +func TestTodoTaskUpdate_should_error_when_task_id_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{ + "title": "新标题", + }) + if err == nil { + t.Fatal("expected error when --task-id is missing") + } +} + +func TestTodoTaskUpdate_should_not_call_mcp_in_dry_run(t *testing.T) { + cap := setupTestDepsWithDryRun(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{ + "task-id": "TASK_001", + "title": "Dry Run", + }) + assertCallCount(t, cap, 0) +} + +// ── task done ─────────────────────────────────────────────── + +func TestTodoTaskDone_should_call_update_todo_done_status(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{ + "task-id": "TASK_001", + "status": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "update_todo_done_status") +} + +func TestTodoTaskDone_should_pass_taskId_and_isDone(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{ + "task-id": "TASK_001", + "status": "true", + }) + assertToolArg(t, cap, "taskId", "TASK_001") + assertToolArg(t, cap, "isDone", "true") +} + +func TestTodoTaskDone_should_error_when_task_id_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{ + "status": "true", + }) + if err == nil { + t.Fatal("expected error when --task-id is missing") + } +} + +func TestTodoTaskDone_should_error_when_status_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{ + "task-id": "TASK_001", + }) + if err == nil { + t.Fatal("expected error when --status is missing") + } +} + +// ── task get ──────────────────────────────────────────────── + +func TestTodoTaskGet_should_call_query_todo_detail(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "get"}, map[string]string{ + "task-id": "TASK_001", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "query_todo_detail") +} + +func TestTodoTaskGet_should_pass_taskId(t *testing.T) { + cap := setupTestDeps(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "get"}, map[string]string{ + "task-id": "TASK_002", + }) + assertToolArg(t, cap, "taskId", "TASK_002") +} + +func TestTodoTaskGet_should_error_when_task_id_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "get"}, nil) + if err == nil { + t.Fatal("expected error when --task-id is missing") + } +} + +// ── task delete ───────────────────────────────────────────── + +func TestTodoTaskDelete_should_call_delete_todo(t *testing.T) { + cap := setupTestDepsAutoConfirm(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{ + "task-id": "TASK_DEL", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertToolName(t, cap, "delete_todo") +} + +func TestTodoTaskDelete_should_pass_taskId(t *testing.T) { + cap := setupTestDepsAutoConfirm(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{ + "task-id": "TASK_DEL", + }) + assertToolArg(t, cap, "taskId", "TASK_DEL") +} + +func TestTodoTaskDelete_should_error_when_task_id_missing(t *testing.T) { + _ = setupTestDeps(t, "todo") + root := buildRoot() + err := execCmd(t, root, []string{"todo", "task", "delete"}, nil) + if err == nil { + t.Fatal("expected error when --task-id is missing") + } +} + +func TestTodoTaskDelete_should_not_call_mcp_in_dry_run(t *testing.T) { + cap := setupTestDepsWithDryRun(t, "todo") + root := buildRoot() + _ = execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{ + "task-id": "TASK_001", + }) + assertCallCount(t, cap, 0) }