Files

491 lines
17 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendar
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"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"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
func calendarContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "calendar " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Result: result,
Pagination: pagination,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: calendarCompositeReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: name,
CanonicalPath: "calendar." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func calendarObjectResult(description string, outcomes ...contract.ResultOutcome) *contract.ResultSpec {
if len(outcomes) == 0 {
outcomes = []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
}
return &contract.ResultSpec{
Outcomes: outcomes,
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"additionalProperties":true}`,
description,
)),
}
}
func calendarCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Calendar 业务条目","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果已完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
description, collection, description, collection,
)),
}
}
func calendarCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func calendarReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func calendarWriteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
}
func calendarResponseError(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),
)
}
func requireCalendarResponse(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, calendarResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空")
}
for _, candidate := range calendarContainers(data) {
value, present := candidate["success"]
if !present {
continue
}
success, ok := value.(bool)
if !ok {
return nil, calendarResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !success {
message := firstCalendarString(candidate, "errorMsg", "errorMessage", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, calendarResponseError(operation, "remote_failure", message)
}
}
return data, nil
}
func requireCalendarWriteResponse(data map[string]any, operation string) (map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, err
}
for _, candidate := range calendarContainers(data) {
if success, ok := candidate["success"].(bool); ok && success {
return data, nil
}
}
return nil, calendarResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据;远端效果未知")
}
func calendarContainers(data map[string]any) []map[string]any {
if data == nil {
return nil
}
containers := []map[string]any{data}
seen := map[mapIdentity]bool{}
for depth, index := 0, 0; index < len(containers) && depth < 12; index, depth = index+1, depth+1 {
candidate := containers[index]
for _, key := range []string{"content", "result", "data", "event"} {
if nested, ok := candidate[key].(map[string]any); ok {
identity := mapIdentity{parent: index, key: key}
if !seen[identity] {
seen[identity] = true
containers = append(containers, nested)
}
}
}
}
return containers
}
type mapIdentity struct {
parent int
key string
}
func requireCalendarObject(data map[string]any, operation string) (map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, err
}
containers := calendarContainers(data)
for index := len(containers) - 1; index >= 0; index-- {
candidate := containers[index]
if len(candidate) == 0 || calendarOnlyEnvelopeFields(candidate) {
continue
}
return candidate, nil
}
return nil, calendarResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
}
func calendarOnlyEnvelopeFields(candidate map[string]any) bool {
for key := range candidate {
switch key {
case "success", "result", "data", "content", "message", "errorMsg", "errorMessage":
default:
return false
}
}
return true
}
func requireCalendarCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, nil, err
}
for _, container := range calendarContainers(data) {
for _, key := range keys {
value, present := container[key]
if !present {
continue
}
items, ok := value.([]any)
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
return items, container, nil
}
}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
if items, ok := value.([]any); ok {
if err := validateCalendarCollectionItems(items, operation, wrapper); err != nil {
return nil, nil, err
}
return items, data, nil
}
}
}
return nil, nil, calendarResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段、内部错误或协议漂移投影成空结果")
}
func validateCalendarCollectionItems(items []any, operation, key string) error {
for index, item := range items {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", key, index))
}
}
return nil
}
func projectCalendarRows(items []any, operation string, aliases map[string][]string, requiredAny ...string) ([]map[string]any, error) {
rows := make([]map[string]any, 0, len(items))
for index, item := range items {
source := item.(map[string]any)
row := make(map[string]any)
for canonical, candidates := range aliases {
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
valid := len(requiredAny) == 0 && len(row) > 0
for _, required := range requiredAny {
if value, ok := row[required]; ok && !calendarEmptyValue(value) {
valid = true
break
}
}
if !valid {
return nil, calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("业务数组第 %d 项缺少可识别的标识或时间字段", index))
}
rows = append(rows, row)
}
return rows, nil
}
func calendarEmptyValue(value any) bool {
if value == nil {
return true
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text) == ""
}
return false
}
type calendarPageEvidence struct {
Known bool
HasMore bool
NextCursor string
}
func calendarPagination(container map[string]any, operation string) (calendarPageEvidence, error) {
page := calendarPageEvidence{}
if container == nil {
return page, nil
}
if raw, present := container["hasMore"]; present {
value, ok := raw.(bool)
if !ok {
return page, calendarResponseError(operation, "malformed_pagination", "响应 hasMore 字段不是布尔值")
}
page.Known = true
page.HasMore = value
}
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
raw, present := container[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
if !ok {
return page, calendarResponseError(operation, "malformed_pagination", fmt.Sprintf("响应 %s 字段不是字符串", key))
}
page.Known = true
page.NextCursor = strings.TrimSpace(value)
break
}
if page.HasMore && page.NextCursor == "" {
return page, calendarResponseError(operation, "missing_next_cursor", "服务端返回 hasMore=true 但没有可续用的 nextCursor,结果不完整")
}
if page.Known && !page.HasMore && page.NextCursor != "" {
return page, calendarResponseError(operation, "inconsistent_pagination", "服务端返回 hasMore=false 但仍携带非空 nextCursor")
}
if page.NextCursor != "" {
page.HasMore = true
}
return page, nil
}
func addCalendarPagination(out map[string]any, page calendarPageEvidence) {
out["complete"] = page.Known && !page.HasMore
if page.Known {
out["hasMore"] = page.HasMore
}
if page.NextCursor != "" {
out["nextCursor"] = page.NextCursor
}
}
// outputCalendarPage keeps legacy JSON compatibility while honoring the
// unified Result contract: cursor controls move to meta.pagination and are not
// duplicated into business data.
func outputCalendarPage(rt *shortcut.RuntimeContext, payload map[string]any, page calendarPageEvidence) error {
if !page.Known {
return calendarResponseError("calendar/pagination", "missing_pagination", "响应缺少分页终态证据")
}
addCalendarPagination(payload, page)
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.NextCursor)
if err != nil {
return calendarResponseError("calendar/pagination", "invalid_pagination", err.Error())
}
business := make(map[string]any, len(payload))
for key, value := range payload {
switch key {
case "hasMore", "nextCursor":
default:
business[key] = value
}
}
meta := &output.Meta{Pagination: pagination}
if count, ok := payload["count"].(int); ok {
meta.Count = output.NewCount(count)
}
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
}
func firstCalendarString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nestedCalendarString(data map[string]any, keys ...string) string {
for _, candidate := range calendarContainers(data) {
if value := firstCalendarString(candidate, keys...); value != "" {
return value
}
}
return ""
}
func validateCalendarRange(startName, startValue, endName, endValue string) (time.Time, time.Time, error) {
start, err := time.Parse(time.RFC3339, strings.TrimSpace(startValue))
if err != nil {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", startName))
}
end, err := time.Parse(time.RFC3339, strings.TrimSpace(endValue))
if err != nil {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", endName))
}
if !end.After(start) {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 必须晚于 --%s", endName, startName))
}
return start, end, nil
}
func calendarStringList(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
return out
}
func calendarEventID(event map[string]any) string {
return firstCalendarString(event, "eventId", "event_id", "id")
}
func calendarTimeEquivalent(actual any, expected string) bool {
want, err := time.Parse(time.RFC3339, expected)
if err != nil {
return false
}
if object, ok := actual.(map[string]any); ok {
for _, key := range []string{"dateTime", "date_time", "value", "timestamp"} {
if nested, exists := object[key]; exists {
return calendarTimeEquivalent(nested, expected)
}
}
}
switch value := actual.(type) {
case string:
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
return parsed.Equal(want)
}
millis, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && time.UnixMilli(millis).Equal(want)
case float64:
return !math.IsNaN(value) && !math.IsInf(value, 0) && value == math.Trunc(value) && time.UnixMilli(int64(value)).Equal(want)
case int64:
return time.UnixMilli(value).Equal(want)
case int:
return time.UnixMilli(int64(value)).Equal(want)
default:
return false
}
}
func verifyCalendarEvent(event map[string]any, eventID string, requested map[string]any) error {
if got := calendarEventID(event); got == "" || got != eventID {
return calendarResponseError("calendar/get_calendar_detail", "readback_id_mismatch", "写后读回的 eventId 缺失或不一致")
}
aliases := map[string][]string{
"summary": {"summary", "title", "name"},
"description": {"description", "desc"},
"startDateTime": {"startDateTime", "start_time", "startTime", "start"},
"endDateTime": {"endDateTime", "end_time", "endTime", "end"},
"timeZone": {"timeZone", "timezone", "time_zone"},
"location": {"location", "locationName", "location_name"},
"freeBusy": {"freeBusy", "free_busy"},
}
for property, expected := range requested {
candidates := aliases[property]
if len(candidates) == 0 {
continue
}
var actual any
var present bool
for _, candidate := range candidates {
actual, present = event[candidate]
if present {
break
}
}
if !present {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_missing", fmt.Sprintf("写后读回缺少字段 %s", property))
}
if property == "startDateTime" || property == "endDateTime" {
if !calendarTimeEquivalent(actual, fmt.Sprint(expected)) {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
}
continue
}
if strings.TrimSpace(fmt.Sprint(actual)) != strings.TrimSpace(fmt.Sprint(expected)) {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
}
}
return nil
}
func finalizeCalendarShortcut(item *shortcut.Shortcut, result *contract.ResultSpec, pagination *contract.PaginationSpec) {
item.OutputRollout = output.RolloutUnifiedActive
item.Contract.Result = result
item.Contract.Pagination = pagination
item.Contract.Selection.AgentSummary = item.Description
item.Contract.Selection.UseWhen = []string{item.Intent}
if item.Contract.Interface == nil {
item.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: calendarCompositeReason}
}
}