341 lines
12 KiB
Go
341 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package smart
|
|
|
|
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/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
)
|
|
|
|
func smartMailError(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 smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
|
if !required && !rt.Changed(flag) {
|
|
return nil
|
|
}
|
|
value := rt.Int(flag)
|
|
if value < 1 || value > 100 {
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) {
|
|
if !rt.Changed(flag) {
|
|
return fallback, nil
|
|
}
|
|
value, err := strconv.Atoi(rt.Str(flag))
|
|
if err != nil || value < 1 || value > 100 {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
|
|
}
|
|
return strconv.Itoa(value), nil
|
|
}
|
|
|
|
func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error {
|
|
_, err := smartMailStringPageSize(rt, flag, "")
|
|
return err
|
|
}
|
|
|
|
func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
|
|
if strings.TrimSpace(rt.Str(flag)) == "" {
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func smartMailSuccess(data map[string]any, operation string) error {
|
|
if len(data) == 0 {
|
|
return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空")
|
|
}
|
|
switch value := data["success"].(type) {
|
|
case bool:
|
|
if value {
|
|
return nil
|
|
}
|
|
case string:
|
|
if value == "true" {
|
|
return nil
|
|
}
|
|
}
|
|
if _, present := data["success"]; !present {
|
|
return smartMailError(operation, "missing_success", "响应缺少 success 业务状态")
|
|
}
|
|
return smartMailError(operation, "remote_failure", "服务未明确返回成功状态")
|
|
}
|
|
|
|
func smartMailLookup(data map[string]any, path string) (any, bool) {
|
|
var current any = data
|
|
for _, segment := range strings.Split(path, ".") {
|
|
object, ok := current.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
current, ok = object[segment]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
|
|
if err := smartMailSuccess(data, operation); err != nil {
|
|
return nil, err
|
|
}
|
|
value, present := smartMailLookup(data, path)
|
|
if !present {
|
|
return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
|
|
}
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
|
|
}
|
|
items := make([]map[string]any, 0, len(raw))
|
|
for index, item := range raw {
|
|
object, ok := item.(map[string]any)
|
|
if !ok || len(object) == 0 {
|
|
return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
|
|
}
|
|
items = append(items, object)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) {
|
|
items, err := smartMailCollection(data, operation, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for index, item := range items {
|
|
if searchMailFirstString(item, identityKeys...) == "" {
|
|
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire
|
|
// shape observed from search_emails: total="0", terminal nextCursor="$", and
|
|
// exactly one placeholder whose only fields are null recipient metadata. It is
|
|
// deliberately narrower than a general "drop bad item" rule.
|
|
func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) {
|
|
items, err := smartMailCollection(data, operation, "messages")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total, totalOK := smartMailInt(data["total"])
|
|
next, nextOK := data["nextCursor"].(string)
|
|
if totalOK && total == 0 && nextOK && (next == "" || next == "$") {
|
|
if len(items) == 0 {
|
|
return items, nil
|
|
}
|
|
if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) {
|
|
return []map[string]any{}, nil
|
|
}
|
|
return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象")
|
|
}
|
|
if totalOK && total < 0 {
|
|
return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数")
|
|
}
|
|
for index, item := range items {
|
|
if searchMailFirstString(item, "id") == "" {
|
|
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func smartMailEmptySearchSentinel(item map[string]any) bool {
|
|
if len(item) == 0 {
|
|
return false
|
|
}
|
|
allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true}
|
|
for key, value := range item {
|
|
if !allowed[key] || value != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func smartMailInt(value any) (int, bool) {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed, true
|
|
case float64:
|
|
if typed != float64(int(typed)) {
|
|
return 0, false
|
|
}
|
|
return int(typed), true
|
|
case string:
|
|
parsed, err := strconv.Atoi(typed)
|
|
return parsed, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
|
|
container := data
|
|
if prefix != "" {
|
|
value, present := smartMailLookup(data, prefix)
|
|
if !present {
|
|
return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象")
|
|
}
|
|
var ok bool
|
|
container, ok = value.(map[string]any)
|
|
if !ok {
|
|
return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误")
|
|
}
|
|
}
|
|
raw, present := container["nextCursor"]
|
|
next, ok := raw.(string)
|
|
if !present || !ok {
|
|
return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor")
|
|
}
|
|
if rawMore, present := container["hasMore"]; present {
|
|
hasMore, ok := smartMailBool(rawMore)
|
|
if !ok {
|
|
return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值")
|
|
}
|
|
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
|
|
if hasMore == terminal {
|
|
return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾")
|
|
}
|
|
if !hasMore {
|
|
return true, "", nil
|
|
}
|
|
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
|
return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
|
}
|
|
return false, next, nil
|
|
}
|
|
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
|
|
return true, "", nil
|
|
}
|
|
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
|
return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
|
}
|
|
return false, next, nil
|
|
}
|
|
|
|
func smartMailBool(value any) (bool, bool) {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed, true
|
|
case string:
|
|
if typed == "true" {
|
|
return true, true
|
|
}
|
|
if typed == "false" {
|
|
return false, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func smartMailResult(collection, description string) *contract.ResultSpec {
|
|
identity := smartMailIdentitySchema(collection)
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
|
SensitivePaths: smartMailSensitivePaths(collection),
|
|
DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
|
description, collection, description, identity, collection)),
|
|
}
|
|
}
|
|
|
|
func smartMailSensitivePaths(collection string) []string {
|
|
switch collection {
|
|
case "messages":
|
|
return []string{"messages.subject", "messages.from", "messages.date"}
|
|
case "mails":
|
|
return []string{"mails.subject", "mails.from", "mails.date"}
|
|
case "users":
|
|
return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"}
|
|
default:
|
|
panic("unsupported smart Mail sensitive paths: " + collection)
|
|
}
|
|
}
|
|
|
|
func smartMailIdentitySchema(collection string) string {
|
|
switch collection {
|
|
case "messages":
|
|
return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]`
|
|
case "mails":
|
|
return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]`
|
|
case "users":
|
|
return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]`
|
|
default:
|
|
panic("unsupported smart Mail Result identity: " + collection)
|
|
}
|
|
}
|
|
|
|
func smartMailCursorPagination() *contract.PaginationSpec {
|
|
return &contract.PaginationSpec{
|
|
Kind: contract.PaginationKindCursor,
|
|
CursorParameter: "cursor",
|
|
MetaPath: contract.PaginationMetaPath,
|
|
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
|
NextTokenPath: contract.PaginationNextTokenPath,
|
|
}
|
|
}
|
|
|
|
func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) {
|
|
declaration.OutputRollout = output.RolloutUnifiedActive
|
|
declaration.Contract.Result = smartMailResult(collection, description)
|
|
declaration.Contract.Pagination = smartMailCursorPagination()
|
|
declaration.Contract.Interface = &contract.InterfaceSpec{
|
|
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
|
|
Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.",
|
|
}
|
|
}
|
|
|
|
func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) {
|
|
declaration.OutputRollout = output.RolloutLegacyOnly
|
|
declaration.Contract.Result = nil
|
|
declaration.Contract.Pagination = nil
|
|
declaration.Contract.Interface = &contract.InterfaceSpec{
|
|
Mode: contract.InterfaceModeComposite,
|
|
Availability: contract.InterfaceAvailable,
|
|
Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.",
|
|
}
|
|
}
|
|
|
|
func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
|
|
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
|
|
if !complete {
|
|
payload["nextCursor"] = next
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
|
|
payload := smartMailPayload(collection, items, complete, next)
|
|
if !output.UsesUnifiedResult(rt.Command()) {
|
|
return rt.Output(payload)
|
|
}
|
|
pagination, err := output.NewPagination(complete, next)
|
|
if err != nil {
|
|
return smartMailError("mail/pagination", "invalid_pagination", err.Error())
|
|
}
|
|
business := map[string]any{"count": len(items), collection: items}
|
|
meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
|
|
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
|
}
|