508 lines
19 KiB
Go
508 lines
19 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 smart
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 calendarSmartMaxPages = 20
|
|
|
|
// calendarDayRange returns the [00:00, next-day 00:00) window for "today plus
|
|
// offsetDays" in the local timezone. Shared by the day-scoped calendar
|
|
// shortcuts (+today offset 0, +tomorrow offset 1, +conflicts --in-days).
|
|
func calendarDayRange(offsetDays int) (time.Time, time.Time) {
|
|
now := time.Now()
|
|
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, offsetDays)
|
|
return start, start.AddDate(0, 0, 1)
|
|
}
|
|
|
|
// calendarProjectEvents lists and projects the events in a list_calendar_events
|
|
// response into clean {title,start,end,location,eventId} items. The strict page
|
|
// parser has already required stable ids and valid time boundaries.
|
|
func calendarProjectEvents(raw []map[string]any) []map[string]any {
|
|
events := make([]map[string]any, 0)
|
|
for _, e := range raw {
|
|
events = append(events, shortcutNextEventProject(e))
|
|
}
|
|
return events
|
|
}
|
|
|
|
func calendarSmartResult(description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"additionalProperties":true}`,
|
|
description,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func finalizeCalendarSmart(item *shortcut.Shortcut, description string) {
|
|
item.OutputRollout = output.RolloutUnifiedActive
|
|
item.Contract.Result = calendarSmartResult(description)
|
|
}
|
|
|
|
func calendarSmartError(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 calendarSmartRequireSuccess(data map[string]any, operation string) (map[string]any, error) {
|
|
if len(data) == 0 {
|
|
return nil, calendarSmartError(operation, "empty_tool_response", "服务返回空响应,无法证明成功或合法空结果")
|
|
}
|
|
raw, present := data["success"]
|
|
if !present {
|
|
return nil, calendarSmartError(operation, "missing_success", "响应缺少 success 终态字段")
|
|
}
|
|
success, ok := raw.(bool)
|
|
if !ok {
|
|
return nil, calendarSmartError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
|
}
|
|
if !success {
|
|
message := calendarSmartFirstString(data, "errorMsg", "errorMessage", "message", "error")
|
|
if message == "" {
|
|
message = "服务明确返回 success=false"
|
|
}
|
|
return nil, calendarSmartError(operation, "remote_failure", message)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func calendarSmartRequireEvent(data map[string]any, operation, expectedID string) (map[string]any, error) {
|
|
data, err := calendarSmartRequireSuccess(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
event, ok := data["result"].(map[string]any)
|
|
if !ok || len(event) == 0 {
|
|
return nil, calendarSmartError(operation, "missing_event", "响应 result 不是非空日程对象")
|
|
}
|
|
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
|
|
if id == "" {
|
|
return nil, calendarSmartError(operation, "missing_event_id", "日程对象缺少稳定 id")
|
|
}
|
|
if expectedID != "" && id != expectedID {
|
|
return nil, calendarSmartError(operation, "event_id_mismatch", "读回日程 id 与请求 eventId 不一致")
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
func calendarSmartFirstString(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 calendarSmartEventID(data map[string]any) string {
|
|
if value := calendarSmartFirstString(data, "id", "eventId", "event_id"); value != "" {
|
|
return value
|
|
}
|
|
for _, key := range []string{"result", "event", "data"} {
|
|
if nested, ok := data[key].(map[string]any); ok {
|
|
if value := calendarSmartFirstString(nested, "id", "eventId", "event_id"); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func calendarSmartListAll(rt *shortcut.RuntimeContext, params map[string]any) ([]map[string]any, error) {
|
|
all := make([]map[string]any, 0)
|
|
seenIDs := map[string]bool{}
|
|
cursor := ""
|
|
for page := 1; page <= calendarSmartMaxPages; page++ {
|
|
request := make(map[string]any, len(params)+2)
|
|
for key, value := range params {
|
|
request[key] = value
|
|
}
|
|
request["limit"] = 100
|
|
if cursor != "" {
|
|
request["cursor"] = cursor
|
|
}
|
|
data, err := rt.CallMCPData("calendar", "list_calendar_events", request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, hasMore, next, err := calendarSmartEventPage(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, event := range items {
|
|
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
|
|
if !seenIDs[id] {
|
|
seenIDs[id] = true
|
|
all = append(all, event)
|
|
}
|
|
}
|
|
if !hasMore {
|
|
return all, nil
|
|
}
|
|
if next == cursor {
|
|
return nil, calendarSmartError("calendar/list_calendar_events", "stalled_cursor", "服务端 nextCursor 未前进,不能证明列表完整")
|
|
}
|
|
cursor = next
|
|
}
|
|
return nil, calendarSmartError("calendar/list_calendar_events", "page_limit", fmt.Sprintf("自动翻页达到 %d 页上限,拒绝基于不完整事件集给出结论", calendarSmartMaxPages))
|
|
}
|
|
|
|
func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string, error) {
|
|
data, err := calendarSmartRequireSuccess(data, "calendar/list_calendar_events")
|
|
if err != nil {
|
|
return nil, false, "", err
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_result", "响应 result 不是对象")
|
|
}
|
|
rawEvents, present := result["events"]
|
|
if !present {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_events", "响应缺少显式 events 数组")
|
|
}
|
|
list, ok := rawEvents.([]any)
|
|
if !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_events", "响应 events 字段不是数组")
|
|
}
|
|
rawHasMore, present := result["hasMore"]
|
|
if !present {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_pagination", "响应缺少 hasMore 分页终态")
|
|
}
|
|
hasMore, ok := rawHasMore.(bool)
|
|
if !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 hasMore 字段不是布尔值")
|
|
}
|
|
next := ""
|
|
if raw, present := result["nextCursor"]; present && raw != nil {
|
|
var ok bool
|
|
next, ok = raw.(string)
|
|
if !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 nextCursor 字段不是字符串")
|
|
}
|
|
next = strings.TrimSpace(next)
|
|
}
|
|
if hasMore && next == "" {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_next_cursor", "服务端返回 hasMore=true 但 nextCursor 为空")
|
|
}
|
|
if !hasMore && next != "" {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
|
|
}
|
|
events := make([]map[string]any, 0, len(list))
|
|
for index, item := range list {
|
|
event, ok := item.(map[string]any)
|
|
if !ok || len(event) == 0 {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event", fmt.Sprintf("events[%d] 不是非空对象", index))
|
|
}
|
|
if calendarSmartFirstString(event, "id", "eventId", "event_id") == "" {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_event_id", fmt.Sprintf("events[%d] 缺少稳定 id", index))
|
|
}
|
|
if _, ok := shortcutNextEventStart(event); !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_start", fmt.Sprintf("events[%d] 缺少合法开始时间", index))
|
|
}
|
|
if _, ok := conflictsEndTime(event); !ok {
|
|
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_end", fmt.Sprintf("events[%d] 缺少合法结束时间", index))
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return events, hasMore, next, nil
|
|
}
|
|
|
|
func calendarSmartBusySlots(data map[string]any) ([]map[string]any, error) {
|
|
data, err := calendarSmartRequireSuccess(data, "calendar/query_busy_status")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, ok := data["result"].([]any)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "missing_result", "响应 result 不是显式忙闲数组")
|
|
}
|
|
out := make([]map[string]any, 0)
|
|
for entryIndex, entry := range entries {
|
|
object, ok := entry.(map[string]any)
|
|
if !ok || len(object) == 0 {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "malformed_result_item", fmt.Sprintf("result[%d] 不是非空对象", entryIndex))
|
|
}
|
|
rawItems, present := object["scheduleItems"]
|
|
if !present {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "missing_schedule_items", fmt.Sprintf("result[%d] 缺少 scheduleItems", entryIndex))
|
|
}
|
|
items, ok := rawItems.([]any)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_items", fmt.Sprintf("result[%d].scheduleItems 不是数组", entryIndex))
|
|
}
|
|
for itemIndex, item := range items {
|
|
schedule, ok := item.(map[string]any)
|
|
if !ok || len(schedule) == 0 {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_item", fmt.Sprintf("scheduleItems[%d] 不是非空对象", itemIndex))
|
|
}
|
|
start := freebusyDateTime(schedule["start"])
|
|
end := freebusyDateTime(schedule["end"])
|
|
if start == nil || end == nil || strings.TrimSpace(fmt.Sprint(start)) == "" || strings.TrimSpace(fmt.Sprint(end)) == "" {
|
|
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_time", "忙闲条目缺少开始或结束时间")
|
|
}
|
|
out = append(out, map[string]any{"start": start, "end": end})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func calendarSmartSuggestedSlots(data map[string]any) ([]map[string]any, error) {
|
|
data, err := calendarSmartRequireSuccess(data, "calendar/list_suggested_event_times")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_result", "响应 result 不是对象")
|
|
}
|
|
rawSlots, present := result["recommendEventTimes"]
|
|
if !present {
|
|
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_suggestions", "响应缺少显式 recommendEventTimes 数组")
|
|
}
|
|
slots, ok := rawSlots.([]any)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestions", "recommendEventTimes 不是数组")
|
|
}
|
|
out := make([]map[string]any, 0, len(slots))
|
|
for index, item := range slots {
|
|
slot, ok := item.(map[string]any)
|
|
if !ok || len(slot) == 0 {
|
|
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion", fmt.Sprintf("recommendEventTimes[%d] 不是非空对象", index))
|
|
}
|
|
start := slot["startTime"]
|
|
end := slot["endTime"]
|
|
if start == nil || end == nil {
|
|
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion_time", fmt.Sprintf("recommendEventTimes[%d] 缺少 startTime/endTime", index))
|
|
}
|
|
row := map[string]any{"start": start, "end": end}
|
|
if raw, ok := slot["timeConflictAttendees"].([]any); ok {
|
|
conflicts := make([]any, 0, len(raw))
|
|
for _, value := range raw {
|
|
if value != nil {
|
|
conflicts = append(conflicts, value)
|
|
}
|
|
}
|
|
if len(conflicts) > 0 {
|
|
row["conflicts"] = conflicts
|
|
}
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func calendarSmartAttendees(data map[string]any) (map[string]bool, error) {
|
|
data, err := calendarSmartRequireSuccess(data, "calendar/get_calendar_participants")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw any
|
|
result, present := data["result"]
|
|
if !present {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_result", "参会人响应缺少 result")
|
|
}
|
|
switch typed := result.(type) {
|
|
case []any:
|
|
raw = typed
|
|
case map[string]any:
|
|
found := false
|
|
for _, key := range []string{"attendees", "participants", "items", "list"} {
|
|
if raw, found = typed[key]; found {
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendees", "响应缺少显式参会人数组")
|
|
}
|
|
default:
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_result", "参会人响应 result 既不是数组也不是含参会人数组的对象")
|
|
}
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendees", "参会人字段不是数组")
|
|
}
|
|
identities := map[string]bool{}
|
|
for index, item := range items {
|
|
attendee, ok := item.(map[string]any)
|
|
if !ok || len(attendee) == 0 {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee", fmt.Sprintf("参会人第 %d 项不是非空对象", index))
|
|
}
|
|
id := calendarSmartFirstString(attendee, "userId", "user_id", "id", "staffId")
|
|
name := calendarSmartFirstString(attendee, "displayName", "display_name", "name", "userName")
|
|
if id == "" && name == "" {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendee_identity", fmt.Sprintf("参会人第 %d 项既无 userId 也无 displayName", index))
|
|
}
|
|
if id != "" {
|
|
identities[id] = true
|
|
}
|
|
if name != "" {
|
|
identities[name] = true
|
|
}
|
|
if rawSelf, present := attendee["self"]; present {
|
|
isSelf, ok := rawSelf.(bool)
|
|
if !ok {
|
|
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee_self", fmt.Sprintf("参会人第 %d 项 self 不是布尔值", index))
|
|
}
|
|
if isSelf {
|
|
identities["__self__"] = true
|
|
}
|
|
}
|
|
}
|
|
return identities, nil
|
|
}
|
|
|
|
func calendarSmartVerifyEventTimes(event map[string]any, start, end string) error {
|
|
startValue, startOK := calendarSmartEventTime(event["start"])
|
|
if !startOK {
|
|
startValue, startOK = calendarSmartEventTime(event["startDateTime"])
|
|
}
|
|
endValue, endOK := calendarSmartEventTime(event["end"])
|
|
if !endOK {
|
|
endValue, endOK = calendarSmartEventTime(event["endDateTime"])
|
|
}
|
|
wantStart, startErr := time.Parse(time.RFC3339, start)
|
|
wantEnd, endErr := time.Parse(time.RFC3339, end)
|
|
if startErr != nil || endErr != nil || !startOK || !endOK || !startValue.Equal(wantStart) || !endValue.Equal(wantEnd) {
|
|
return calendarSmartError("calendar/get_calendar_detail", "readback_time_mismatch", "写后读回的开始/结束时间与请求不一致")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func calendarSmartEventTime(value any) (time.Time, bool) {
|
|
if object, ok := value.(map[string]any); ok {
|
|
value = object["dateTime"]
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok || strings.TrimSpace(text) == "" {
|
|
return time.Time{}, false
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, text)
|
|
return parsed, err == nil
|
|
}
|
|
|
|
func calendarSmartNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
for _, marker := range []string{"not found", "not_found", "不存在", "未找到", "404"} {
|
|
if strings.Contains(message, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func calendarSmartValidateRange(start, end string) error {
|
|
startTime, startErr := time.Parse(time.RFC3339, strings.TrimSpace(start))
|
|
if startErr != nil {
|
|
return apperrors.NewValidation("--start 时间格式无效,请使用 RFC3339/ISO8601")
|
|
}
|
|
endTime, endErr := time.Parse(time.RFC3339, strings.TrimSpace(end))
|
|
if endErr != nil {
|
|
return apperrors.NewValidation("--end 时间格式无效,请使用 RFC3339/ISO8601")
|
|
}
|
|
if !endTime.After(startTime) {
|
|
return apperrors.NewValidation("--end 必须晚于 --start")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func calendarSmartWriteReceipt(data map[string]any, operation string) error {
|
|
_, err := calendarSmartRequireSuccess(data, operation)
|
|
return err
|
|
}
|
|
|
|
func calendarSmartVerifyCreatedEvent(event map[string]any, eventID, title, start, end string) error {
|
|
if id := calendarSmartFirstString(event, "id", "eventId", "event_id"); id != eventID {
|
|
return calendarSmartError("calendar/get_calendar_detail", "readback_event_id_mismatch", "创建后读回的日程 id 与创建回执不一致")
|
|
}
|
|
if got := calendarSmartFirstString(event, "summary", "title"); got != strings.TrimSpace(title) {
|
|
return calendarSmartError("calendar/get_calendar_detail", "readback_title_mismatch", "创建后读回的日程标题与请求不一致")
|
|
}
|
|
return calendarSmartVerifyEventTimes(event, start, end)
|
|
}
|
|
|
|
func calendarSmartVerifyAttendees(present map[string]bool, expectedIDs, expectedNames []string, currentUserID string) error {
|
|
for index, id := range expectedIDs {
|
|
name := ""
|
|
if index < len(expectedNames) {
|
|
name = expectedNames[index]
|
|
}
|
|
isCurrentUser := currentUserID != "" && id == currentUserID && present["__self__"]
|
|
if !present[id] && (name == "" || !present[name]) && !isCurrentUser {
|
|
return calendarSmartError("calendar/get_calendar_participants", "readback_attendee_missing", "写后读回未找到请求添加的参会人")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func calendarSmartCurrentUserID(rt *shortcut.RuntimeContext, attendees map[string]bool) (string, error) {
|
|
if !attendees["__self__"] {
|
|
return "", nil
|
|
}
|
|
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
userID := myAttendanceCurrentUserID(profile)
|
|
if userID == "" {
|
|
return "", calendarSmartError("contact/get_current_user_profile", "missing_current_user_id", "参会人读回只标记 self,但无法解析当前用户 userId")
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
func calendarSmartDeleteAndVerify(rt *shortcut.RuntimeContext, eventID string) error {
|
|
written, err := rt.CallMCPWriteDataStrict("calendar", "delete_calendar_event", map[string]any{"eventId": eventID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := calendarSmartWriteReceipt(written, "calendar/delete_calendar_event"); err != nil {
|
|
return err
|
|
}
|
|
data, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
|
|
if err != nil {
|
|
if calendarSmartNotFound(err) {
|
|
return nil
|
|
}
|
|
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_unknown", "删除后读回失败,无法证明日程已不存在")
|
|
}
|
|
event, err := calendarSmartRequireEvent(data, "calendar/get_calendar_detail", eventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
status := strings.ToLower(calendarSmartFirstString(event, "status", "eventStatus", "event_status"))
|
|
if status == "cancelled" || status == "canceled" || status == "deleted" {
|
|
return nil
|
|
}
|
|
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_present", "删除后读回日程仍然存在")
|
|
}
|