348 lines
12 KiB
Go
348 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package todo
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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/shortcut"
|
|
)
|
|
|
|
const (
|
|
todoPageSize = 20
|
|
todoMaxPages = 40
|
|
)
|
|
|
|
func todoObjectResult(description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"additionalProperties":true}`,
|
|
description,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func todoCollectionResult(collection, description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"待办结果条目","additionalProperties":true}}},"required":["count",%q],"additionalProperties":true}`,
|
|
description, collection, description, collection,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func todoPagedCollectionResult(collection, description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"待办结果条目","additionalProperties":true}},"page":{"type":"integer","description":"当前页码;全量模式省略"},"size":{"type":"integer","description":"当前页大小;全量模式省略"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页;全量模式省略"},"complete":{"type":"boolean","description":"全量模式是否确认遍历耗尽"}},"required":["count",%q],"additionalProperties":false}`,
|
|
description, collection, description, collection,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func requireTodoResponse(data map[string]any, operation string) (map[string]any, error) {
|
|
if len(data) == 0 {
|
|
return nil, todoResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明请求成功")
|
|
}
|
|
if raw, ok := data["success"]; ok {
|
|
success, valid := raw.(bool)
|
|
if !valid {
|
|
return nil, todoResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
|
}
|
|
if !success {
|
|
return nil, todoResponseError(operation, "remote_failure", "服务明确返回 success=false")
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireTodoWriteReceipt(data map[string]any, operation string) error {
|
|
data, err := requireTodoResponse(data, operation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
success, ok := data["success"].(bool)
|
|
if !ok || !success {
|
|
return todoResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireTodoCollection(data map[string]any, operation string, keys ...string) ([]map[string]any, error) {
|
|
data, err := requireTodoResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, found, err := findTodoCollection(data, keys, 0)
|
|
if err != nil {
|
|
return nil, todoResponseError(operation, "malformed_collection", err.Error())
|
|
}
|
|
if !found {
|
|
return nil, todoResponseError(operation, "missing_collection", "响应缺少预期列表容器")
|
|
}
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for i, item := range raw {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
return nil, todoResponseError(operation, "malformed_item", fmt.Sprintf("列表第 %d 项不是对象", i))
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func todoHasMore(data map[string]any) (bool, error) {
|
|
return findTodoHasMore(data, 0)
|
|
}
|
|
|
|
func findTodoHasMore(m map[string]any, depth int) (bool, error) {
|
|
if depth > 4 {
|
|
return false, todoResponseError("todo/pagination", "missing_has_more", "响应缺少 hasMore,无法证明分页已完成")
|
|
}
|
|
if raw, ok := m["hasMore"]; ok {
|
|
value, valid := raw.(bool)
|
|
if !valid {
|
|
return false, todoResponseError("todo/pagination", "malformed_has_more", "响应 hasMore 字段不是布尔值")
|
|
}
|
|
return value, nil
|
|
}
|
|
for _, key := range []string{"result", "data"} {
|
|
if raw, ok := m[key]; ok {
|
|
child, valid := raw.(map[string]any)
|
|
if !valid {
|
|
return false, todoResponseError("todo/pagination", "malformed_wrapper", key+" 包装层不是对象")
|
|
}
|
|
return findTodoHasMore(child, depth+1)
|
|
}
|
|
}
|
|
return false, todoResponseError("todo/pagination", "missing_has_more", "响应缺少 hasMore,无法证明分页已完成")
|
|
}
|
|
|
|
func listAllTodoCards(rt *shortcut.RuntimeContext, base map[string]any, maxPages int) ([]map[string]any, error) {
|
|
all := make([]map[string]any, 0)
|
|
for page := 1; page <= maxPages; page++ {
|
|
params := make(map[string]any, len(base)+2)
|
|
for key, value := range base {
|
|
params[key] = value
|
|
}
|
|
params["pageNum"] = strconv.Itoa(page)
|
|
params["pageSize"] = strconv.Itoa(todoPageSize)
|
|
data, err := rt.CallMCPReadData("todo", "get_user_todos_in_current_org", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pageItems, err := getMyTasksProjectStrict(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hasMore, err := todoHasMore(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, pageItems...)
|
|
if !hasMore {
|
|
return all, nil
|
|
}
|
|
}
|
|
return nil, todoResponseError("todo/get_user_todos_in_current_org", "pagination_limit_reached", fmt.Sprintf("连续 %d 页仍返回 hasMore=true,拒绝把不完整结果当作完整结果", maxPages))
|
|
}
|
|
|
|
func findTodoCollection(m map[string]any, keys []string, depth int) ([]any, bool, error) {
|
|
if depth > 4 {
|
|
return nil, false, nil
|
|
}
|
|
for _, key := range keys {
|
|
if raw, ok := m[key]; ok {
|
|
arr, valid := raw.([]any)
|
|
if !valid {
|
|
return nil, false, fmt.Errorf("%s 容器不是数组", key)
|
|
}
|
|
return arr, true, nil
|
|
}
|
|
}
|
|
for _, key := range []string{"result", "data"} {
|
|
raw, ok := m[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if arr, valid := raw.([]any); valid {
|
|
return arr, true, nil
|
|
}
|
|
child, valid := raw.(map[string]any)
|
|
if !valid {
|
|
return nil, false, fmt.Errorf("%s 包装层既不是对象也不是数组", key)
|
|
}
|
|
if arr, found, err := findTodoCollection(child, keys, depth+1); found || err != nil {
|
|
return arr, found, err
|
|
}
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
func requireTodoDetail(data map[string]any, operation, expectedTaskID string) (map[string]any, error) {
|
|
data, err := requireTodoResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
detail, found, err := findTodoObject(data, []string{"todoDetailModel", "todo", "task"}, 0)
|
|
if err != nil {
|
|
return nil, todoResponseError(operation, "malformed_detail", err.Error())
|
|
}
|
|
if !found {
|
|
// Some deployments return the detail itself under result/data.
|
|
if result, ok := data["result"].(map[string]any); ok && todoStableString(result, "taskId", "todoId", "id") != "" {
|
|
detail, found = result, true
|
|
} else if todoStableString(data, "taskId", "todoId", "id") != "" {
|
|
detail, found = data, true
|
|
}
|
|
}
|
|
if !found || len(detail) == 0 {
|
|
return nil, todoResponseError(operation, "missing_detail", "响应缺少有效待办详情对象")
|
|
}
|
|
actual := todoStableString(detail, "taskId", "todoId", "id")
|
|
if actual == "" {
|
|
return nil, todoResponseError(operation, "missing_stable_id", "待办详情缺少稳定 taskId")
|
|
}
|
|
if expectedTaskID != "" && actual != expectedTaskID {
|
|
return nil, todoResponseError(operation, "identity_mismatch", "读回的 taskId 与请求不一致")
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
func findTodoObject(m map[string]any, keys []string, depth int) (map[string]any, bool, error) {
|
|
if depth > 4 {
|
|
return nil, false, nil
|
|
}
|
|
for _, key := range keys {
|
|
if raw, ok := m[key]; ok {
|
|
object, valid := raw.(map[string]any)
|
|
if !valid {
|
|
return nil, false, fmt.Errorf("%s 容器不是对象", key)
|
|
}
|
|
return object, true, nil
|
|
}
|
|
}
|
|
for _, key := range []string{"result", "data"} {
|
|
raw, ok := m[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
child, valid := raw.(map[string]any)
|
|
if !valid {
|
|
return nil, false, fmt.Errorf("%s 包装层不是对象", key)
|
|
}
|
|
if object, found, err := findTodoObject(child, keys, depth+1); found || err != nil {
|
|
return object, found, err
|
|
}
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
func todoCreatedTaskID(data map[string]any) string {
|
|
return findTodoStableString(data, []string{"taskId", "todoId", "id"}, 0)
|
|
}
|
|
|
|
func findTodoStableString(m map[string]any, keys []string, depth int) string {
|
|
if depth > 5 {
|
|
return ""
|
|
}
|
|
if value := todoStableString(m, keys...); value != "" {
|
|
return value
|
|
}
|
|
for _, key := range []string{"result", "data", "todoDetailModel", "todo", "task"} {
|
|
if child, ok := m[key].(map[string]any); ok {
|
|
if value := findTodoStableString(child, keys, depth+1); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func todoStableString(m map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
switch value := m[key].(type) {
|
|
case string:
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return value
|
|
}
|
|
case json.Number:
|
|
return value.String()
|
|
case float64:
|
|
if value == float64(int64(value)) {
|
|
return strconv.FormatInt(int64(value), 10)
|
|
}
|
|
case int:
|
|
return strconv.Itoa(value)
|
|
case int64:
|
|
return strconv.FormatInt(value, 10)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readTodoDetail(rt *shortcut.RuntimeContext, taskID string) (map[string]any, error) {
|
|
data, err := rt.CallMCPReadData("todo", "get_todo_detail", map[string]any{"taskId": taskID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return requireTodoDetail(data, "todo/get_todo_detail", taskID)
|
|
}
|
|
|
|
// VerifyCreatedTodo is shared by Todo-owned smart shortcuts that create a
|
|
// personal todo. It requires a terminal receipt, a stable taskId and a detail
|
|
// read-back bound to that exact ID.
|
|
func VerifyCreatedTodo(rt *shortcut.RuntimeContext, data map[string]any, operation, expectedSubject string) (string, map[string]any, error) {
|
|
if err := requireTodoWriteReceipt(data, operation); err != nil {
|
|
return "", nil, err
|
|
}
|
|
taskID := todoCreatedTaskID(data)
|
|
if taskID == "" {
|
|
return "", nil, todoResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
|
}
|
|
detail, err := readTodoDetail(rt, taskID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if subject, _ := detail["subject"].(string); expectedSubject != "" && subject != expectedSubject {
|
|
return "", nil, todoResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
|
|
}
|
|
return taskID, detail, nil
|
|
}
|
|
|
|
// VerifyDoneStatus requires a terminal write receipt and verifies isDone on a
|
|
// detail read bound to the same taskId.
|
|
func VerifyDoneStatus(rt *shortcut.RuntimeContext, data map[string]any, taskID string, expected bool) error {
|
|
if err := requireTodoWriteReceipt(data, "todo/update_todo_done_status"); err != nil {
|
|
return err
|
|
}
|
|
detail, err := readTodoDetail(rt, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
actual, ok := detail["isDone"].(bool)
|
|
if !ok || actual != expected {
|
|
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func todoResponseError(operation, reason, message string) error {
|
|
return apperrors.NewAPI(message,
|
|
apperrors.WithOperation(operation),
|
|
apperrors.WithOrigin("mcp"),
|
|
apperrors.WithFailureStage("response_validation"),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithReason(reason),
|
|
)
|
|
}
|