Files
dingtalk-workspace-cli/internal/shortcut/smart/todo_done.go

220 lines
8.7 KiB
Go
Raw Permalink 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.
// 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 smart
import (
"encoding/json"
"fmt"
"strings"
"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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
)
// TodoDone: mark one of MY todos complete by matching a keyword in its title.
//
// Steps:
//
// 1. list my todos via get_user_todos_in_current_org (pageNum / pageSize as
// strings, mirroring helpers.todo list);
//
// 2. scan result.todoCards[] and keep the ones whose subject contains --task;
// none → "没找到匹配待办", many → list candidates (subject + taskId) so the
// caller can be more specific, exactly one → take its taskId;
//
// 3. mark it done via update_todo_done_status (taskId + isDone="true",
// mirroring `dws todo task done`).
//
// dws todo +todo-done --task 周报
var TodoDone = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "todo",
Command: "+todo-done",
Product: "todo",
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
Intent: "当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;" +
"内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」," +
"匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "todo",
Name: "shortcut_todo_done",
CanonicalPath: "todo.shortcut_todo_done",
CLIPath: "todo +todo-done",
PrimaryCLIPath: "todo +todo-done",
},
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的完成结果","properties":{"taskId":{"type":"string","description":"已完成待办 taskId"},"subject":{"type":"string","description":"已完成待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
UseWhen: []string{"当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」,匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +todo-done --task 周报"},
},
},
Flags: []shortcut.Flag{
{Name: "task", Type: shortcut.FlagString, Desc: "待办标题关键词", Required: true},
},
Tips: []string{`dws todo +todo-done --task 周报`},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("task"))
if keyword == "" {
return apperrors.NewValidation("请用 --task 提供待办标题关键词")
}
// Step 1 — list ALL my todos across pages (roleTypes defaults to executor,
// mirroring helpers.buildListTodoTaskArgs) so a match beyond the first page
// is still found instead of yielding a false "没找到匹配待办".
cards, err := shortcutListAllTodoCards(rt, map[string]any{
"roleTypes": []string{"executor"},
})
if err != nil {
return err
}
// Step 2 — match by subject substring.
matches := shortcutTodoMatch(cards, keyword)
switch {
case len(matches) == 0:
return apperrors.NewValidation(fmt.Sprintf("没找到匹配待办:标题里没有 %q 的待办。", keyword))
case len(matches) > 1:
return apperrors.NewValidation(fmt.Sprintf(
"%q 匹配到 %d 条待办,请用更精确的关键词,或用 `dws todo task done --task-id` 指定:%s",
keyword, len(matches), strings.Join(shortcutTodoLabels(matches), ";")))
}
// Step 3 — mark it done. taskId + isDone mirror helpers `todo task done`
// (update_todo_done_status, isDone passed as a string).
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": matches[0].taskID})
}
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{
"taskId": matches[0].taskID,
"isDone": "true",
})
if err != nil {
return err
}
if err := todoshortcut.VerifyDoneStatus(rt, data, matches[0].taskID, true); err != nil {
return err
}
return rt.Output(map[string]any{"taskId": matches[0].taskID, "subject": matches[0].subject, "verified": true})
},
}
// shortcutTodoCard is the minimal identity we need from a todoCards entry.
type shortcutTodoCard struct {
taskID string
subject string
}
// shortcutTodoMatch walks the todoCards ({subject, taskId, ...}) and returns the
// cards whose subject contains keyword (case-insensitive).
func shortcutTodoMatch(cards []map[string]any, keyword string) []shortcutTodoCard {
kw := strings.ToLower(keyword)
var out []shortcutTodoCard
for _, m := range cards {
subject, _ := m["subject"].(string)
if subject == "" {
continue
}
if !strings.Contains(strings.ToLower(subject), kw) {
continue
}
id := shortcutTodoTaskID(m)
if id == "" {
continue
}
out = append(out, shortcutTodoCard{taskID: id, subject: subject})
}
return out
}
// shortcutTodoCards pulls the todoCards list out of the response, probing
// wrapper layers defensively. Real MCP responses may arrive as either
// {"result":{"todoCards":[...]}} or {"result":{"result":{"todoCards":[...]}}}
// depending on the call path, so do not assume a single fixed wrapper.
func shortcutTodoCards(data map[string]any) []map[string]any {
if arr := shortcutTodoFindCards(data, 0); arr != nil {
return shortcutTodoToMaps(arr)
}
return nil
}
func shortcutTodoFindCards(m map[string]any, depth int) []any {
if depth > 4 {
return nil
}
if arr, ok := m["todoCards"].([]any); ok {
return arr
}
for _, k := range []string{"result", "data"} {
if child, ok := m[k].(map[string]any); ok {
if arr := shortcutTodoFindCards(child, depth+1); arr != nil {
return arr
}
}
}
return nil
}
func shortcutTodoToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// shortcutTodoTaskID reads a card's taskId, tolerating both string and numeric
// JSON encodings.
func shortcutTodoTaskID(m map[string]any) string {
switch v := m["taskId"].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
}
return ""
}
func shortcutTodoLabels(cards []shortcutTodoCard) []string {
out := make([]string, 0, len(cards))
for _, c := range cards {
out = append(out, fmt.Sprintf("%s(taskId=%s)", c.subject, c.taskID))
}
return out
}
func init() {
shortcut.Register(TodoDone)
}