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

560 lines
17 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
const todoListPageSizeMax = 20
func init() {
RegisterPublic(func() Handler {
return todoHandler{}
})
}
type todoHandler struct{}
func (todoHandler) Name() string {
return "todo"
}
func (todoHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "todo",
Short: i18n.T("待办任务管理"),
Long: i18n.T("管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
task := &cobra.Command{
Use: "task",
Short: i18n.T("创建 / 查询 / 更新 / 删除待办"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
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",
Short: i18n.T("查询待办列表"),
Example: ` dws todo task list --page 1 --size 20 --status false`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
page, _ := cmd.Flags().GetString("page")
sizeRaw, _ := cmd.Flags().GetString("size")
status, _ := cmd.Flags().GetString("status")
page = normalizePage(page)
size := normalizeSize(sizeRaw)
summaryParams := todoListRequestParams(page, strconv.Itoa(size), status)
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"todo",
"get_user_todos_in_current_org",
summaryParams,
)
if size <= todoListPageSizeMax {
invocation.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
if commandDryRun(cmd) {
invocation.DryRun = true
return writeCommandPayload(cmd, todoListPreviewResult(invocation, size, "automatic pagination preview"))
}
startPage, _ := strconv.Atoi(page)
if startPage < 1 {
startPage = 1
}
merged := make([]any, 0, size)
for pageNum := startPage; len(merged) < size; pageNum++ {
pageParams := todoListRequestParams(strconv.Itoa(pageNum), strconv.Itoa(todoListPageSizeMax), status)
pageInvocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"todo",
"get_user_todos_in_current_org",
pageParams,
)
pageResult, err := runner.Run(cmd.Context(), pageInvocation)
if err != nil {
return err
}
if !pageResult.Invocation.Implemented && len(helperResponseContent(pageResult)) == 0 {
invocation.DryRun = pageResult.Invocation.DryRun
return writeCommandPayload(cmd, todoListPreviewResult(invocation, size, "automatic pagination requires runtime execution"))
}
cards := todoCardsFromResult(pageResult)
if len(cards) == 0 {
break
}
for _, card := range cards {
merged = append(merged, card)
if len(merged) >= size {
break
}
}
if len(cards) < todoListPageSizeMax {
break
}
}
invocation.Implemented = true
return writeCommandPayload(cmd, executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"result": map[string]any{
"todoCards": merged,
},
},
},
})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("page", "1", i18n.T("页码 (必填)"))
cmd.Flags().String("size", "20", i18n.T("获取数量,超过 20 自动分页 (默认 20)"))
cmd.Flags().String("status", "", i18n.T("true=已完成, false=未完成"))
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 <taskId> --title "新标题"
dws todo task update --task-id <taskId> --priority 40 --due "2026-03-10T18:00:00+08:00"
dws todo task update --task-id <taskId> --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 <taskId> --status true
dws todo task done --task-id <taskId> --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>
# 查询 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 <taskId>
dws todo task delete --task-id <taskId> --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
}
return "1"
}
func normalizeSize(raw string) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 1 {
return todoListPageSizeMax
}
return value
}
func estimateTodoListRequests(size int) int {
return (size + todoListPageSizeMax - 1) / todoListPageSizeMax
}
func todoListPreviewResult(invocation executor.Invocation, size int, note string) executor.Result {
response := map[string]any{
"estimated_requests": estimateTodoListRequests(size),
"page_size_limit": todoListPageSizeMax,
"note": note,
}
if invocation.DryRun {
response["dry_run"] = true
}
return executor.Result{
Invocation: invocation,
Response: response,
}
}
func todoListRequestParams(page, pageSize, status string) map[string]any {
pageSize = strings.TrimSpace(pageSize)
if pageSize == "" {
pageSize = strconv.Itoa(todoListPageSizeMax)
}
params := map[string]any{
"pageNum": normalizePage(page),
"pageSize": pageSize,
}
status = strings.TrimSpace(status)
if status != "" {
params["isDone"] = status
params["todoStatus"] = status
}
return params
}
func todoCardsFromResult(result executor.Result) []any {
content := helperResponseContent(result)
if len(content) == 0 {
return nil
}
if payload, ok := content["result"].(map[string]any); ok {
if cards, ok := payload["todoCards"].([]any); ok {
return cards
}
}
if cards, ok := content["todoCards"].([]any); ok {
return cards
}
return nil
}