Files

442 lines
14 KiB
Go

// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
// Package aitabletarget provides deterministic AI Table URL and name
// resolution shared by read and write shortcuts. It never guesses among
// multiple candidates and treats an incomplete/unknown response as an error.
package aitabletarget
import (
"fmt"
"net/url"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
maxResolutionPages = 50
aliDocsHost = "alidocs.dingtalk.com"
)
// Reader is the read-only transport required for name resolution.
type Reader interface {
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
}
// Target is the normalized identity carried by an AI Table URL.
type Target struct {
SchemaVersion string `json:"schemaVersion"`
Source string `json:"source"`
Kind string `json:"kind"`
BaseID string `json:"baseId"`
TableID string `json:"tableId,omitempty"`
ViewID string `json:"viewId,omitempty"`
RecordID string `json:"recordId,omitempty"`
}
// Candidate is a stable ID/name pair returned by name resolution.
type Candidate struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Resolution is returned only after exactly one candidate is selected.
type Resolution struct {
Status string `json:"status"`
EntityType string `json:"entityType"`
Query string `json:"query"`
MatchType string `json:"matchType"`
Selected Candidate `json:"selected"`
}
// ParseURL parses the documented DingTalk AI Table URL forms:
//
// https://alidocs.dingtalk.com/i/nodes/{baseId}
// https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}
func ParseURL(raw string) (Target, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.ParseRequestURI(raw)
if err != nil {
return Target{}, invalidURL(raw, fmt.Sprintf("URL 解析失败: %v", err))
}
if !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), aliDocsHost) || parsed.User != nil {
return Target{}, invalidURL(raw, "仅接受 https://alidocs.dingtalk.com 的无凭据 URL")
}
segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/")
if len(segments) != 3 || segments[0] != "i" || segments[1] != "nodes" {
return Target{}, invalidURL(raw, "路径必须是 /i/nodes/{baseId}")
}
baseID, err := url.PathUnescape(segments[2])
if err != nil || !validID(baseID) {
return Target{}, invalidURL(raw, "URL 路径中的 baseId 无效")
}
values := parsed.Query()
nested, err := parseIframeQuery(values.Get("iframeQuery"))
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
for key, nestedValues := range nested {
for _, value := range nestedValues {
values.Add(key, value)
}
}
tableID, err := uniqueQueryID(values, "sheetId", "tableId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
viewID, err := uniqueQueryID(values, "viewId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
recordID, err := uniqueQueryID(values, "recordId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
if viewID != "" && tableID == "" {
return Target{}, invalidURL(raw, "viewId 缺少所属 sheetId/tableId")
}
if recordID != "" && tableID == "" {
return Target{}, invalidURL(raw, "recordId 缺少所属 sheetId/tableId")
}
kind := "base"
if tableID != "" {
kind = "table"
}
if viewID != "" {
kind = "view"
}
if recordID != "" {
kind = "record"
}
return Target{
SchemaVersion: "aitable.target.v1",
Source: "url",
Kind: kind,
BaseID: strings.TrimSpace(baseID),
TableID: tableID,
ViewID: viewID,
RecordID: recordID,
}, nil
}
func parseIframeQuery(raw string) (url.Values, error) {
if strings.TrimSpace(raw) == "" {
return url.Values{}, nil
}
value := raw
for attempt := 0; attempt < 2 && !strings.Contains(value, "="); attempt++ {
decoded, err := url.QueryUnescape(value)
if err != nil {
return nil, fmt.Errorf("iframeQuery 解码失败: %w", err)
}
if decoded == value {
break
}
value = decoded
}
values, err := url.ParseQuery(value)
if err != nil {
return nil, fmt.Errorf("iframeQuery 不是合法查询串: %w", err)
}
return values, nil
}
func uniqueQueryID(values url.Values, keys ...string) (string, error) {
unique := map[string]struct{}{}
for _, key := range keys {
for _, raw := range values[key] {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if !validID(value) {
return "", fmt.Errorf("%s 包含无效 ID", key)
}
unique[value] = struct{}{}
}
}
if len(unique) > 1 {
return "", fmt.Errorf("%s 出现互相冲突的多个值", strings.Join(keys, "/"))
}
for value := range unique {
return value, nil
}
return "", nil
}
func validID(value string) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
return false
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return false
}
}
return true
}
// ResolveBaseName resolves one Base by exact name. A substring match is used
// only when allowFuzzy is true. Zero/multiple candidates and incomplete pages
// are structured non-zero errors.
func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, error) {
query := strings.TrimSpace(name)
if query == "" {
return Resolution{}, apperrors.NewValidation("Base 名称不能为空")
}
all := make([]Candidate, 0)
cursor := ""
seen := map[string]bool{}
for page := 1; page <= maxResolutionPages; page++ {
params := map[string]any{"query": query}
if cursor != "" {
params["cursor"] = cursor
}
data, err := reader.CallMCPData("aitable", "search_bases", params)
if err != nil {
return Resolution{}, err
}
items, found, listErr := findObjectList(data, "bases", "items", "list", "result", "records")
if listErr != nil {
return Resolution{}, invalidResponse("base", query, listErr.Error())
}
if !found {
return Resolution{}, invalidResponse("base", query, "search_bases 响应缺少候选列表")
}
for index, item := range items {
id := firstString(item, "baseId", "base_id", "id")
candidateName := firstString(item, "baseName", "name", "title")
if id == "" || candidateName == "" {
return Resolution{}, invalidResponse("base", query,
fmt.Sprintf("search_bases 候选 %d 缺少 baseId 或名称", index))
}
all = append(all, Candidate{ID: id, Name: candidateName})
}
next, hasMore, hasMoreKnown := pagination(data)
if hasMoreKnown && !hasMore {
return selectCandidate("base", query, dedupe(all), allowFuzzy)
}
if next == "" {
if hasMoreKnown && hasMore {
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
}
return selectCandidate("base", query, dedupe(all), allowFuzzy)
}
if seen[next] || next == cursor {
return Resolution{}, incomplete("base", query, all, "search_bases cursor 停滞或成环")
}
seen[next] = true
cursor = next
}
return Resolution{}, incomplete("base", query, all, fmt.Sprintf("达到 %d 页安全上限", maxResolutionPages))
}
// ResolveTableName resolves one table within a known Base using get_tables.
func ResolveTableName(reader Reader, baseID, name string, allowFuzzy bool) (Resolution, error) {
baseID = strings.TrimSpace(baseID)
query := strings.TrimSpace(name)
if !validID(baseID) || query == "" {
return Resolution{}, apperrors.NewValidation("解析数据表名称需要合法 baseId 和非空名称")
}
data, err := reader.CallMCPData("aitable", "get_tables", map[string]any{"baseId": baseID})
if err != nil {
return Resolution{}, err
}
items, found, listErr := findObjectList(data, "tables", "items", "list", "result", "records")
if listErr != nil {
return Resolution{}, invalidResponse("table", query, listErr.Error())
}
if !found {
return Resolution{}, invalidResponse("table", query, "get_tables 响应缺少 tables 列表")
}
candidates := make([]Candidate, 0, len(items))
for index, item := range items {
id := firstString(item, "tableId", "table_id", "id")
candidateName := firstString(item, "tableName", "name", "title")
if id == "" || candidateName == "" {
return Resolution{}, invalidResponse("table", query,
fmt.Sprintf("get_tables 候选 %d 缺少 tableId 或名称", index))
}
candidates = append(candidates, Candidate{ID: id, Name: candidateName})
}
return selectCandidate("table", query, dedupe(candidates), allowFuzzy)
}
func selectCandidate(entityType, query string, candidates []Candidate, allowFuzzy bool) (Resolution, error) {
exact := make([]Candidate, 0)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
exact = append(exact, candidate)
}
}
selected := exact
matchType := "exact"
if len(selected) == 0 && allowFuzzy {
matchType = "fuzzy"
needle := strings.ToLower(query)
for _, candidate := range candidates {
if strings.Contains(strings.ToLower(candidate.Name), needle) {
selected = append(selected, candidate)
}
}
}
if len(selected) == 0 {
return Resolution{}, resolutionError("not_found", entityType, query, candidates)
}
if len(selected) > 1 {
return Resolution{}, resolutionError("ambiguous", entityType, query, selected)
}
return Resolution{
Status: "resolved", EntityType: entityType, Query: query,
MatchType: matchType, Selected: selected[0],
}, nil
}
func findObjectList(data map[string]any, keys ...string) ([]map[string]any, bool, error) {
if data == nil {
return nil, false, nil
}
invalid := make([]string, 0)
for _, key := range keys {
value, exists := data[key]
if !exists {
continue
}
if list, ok := value.([]any); ok {
out := make([]map[string]any, 0, len(list))
for index, row := range list {
object, ok := row.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("候选列表 %s[%d] 必须是对象,got %T", key, index, row)
}
out = append(out, object)
}
return out, true, nil
}
if object, ok := value.(map[string]any); ok {
if list, found, err := findObjectList(object, keys...); found || err != nil {
return list, found, err
}
continue
}
invalid = append(invalid, fmt.Sprintf("%s=%T", key, value))
}
for _, envelope := range []string{"data", "result", "response"} {
if object, ok := data[envelope].(map[string]any); ok {
if list, found, err := findObjectList(object, keys...); found || err != nil {
return list, found, err
}
}
}
if len(invalid) > 0 {
return nil, false, fmt.Errorf("候选列表字段类型无效: %v", invalid)
}
return nil, false, nil
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func pagination(data map[string]any) (cursor string, hasMore bool, hasMoreKnown bool) {
if data == nil {
return "", false, false
}
for _, key := range []string{"nextCursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
cursor = strings.TrimSpace(value)
break
}
}
if value, ok := data["hasMore"].(bool); ok {
hasMore, hasMoreKnown = value, true
}
for _, key := range []string{"data", "result", "page", "pagination", "meta"} {
if nested, ok := data[key].(map[string]any); ok {
next, more, known := pagination(nested)
if cursor == "" {
cursor = next
}
if !hasMoreKnown && known {
hasMore, hasMoreKnown = more, true
}
}
}
return cursor, hasMore, hasMoreKnown
}
func dedupe(values []Candidate) []Candidate {
seen := map[string]bool{}
out := make([]Candidate, 0, len(values))
for _, value := range values {
if value.ID == "" || seen[value.ID] {
continue
}
seen[value.ID] = true
out = append(out, value)
}
return out
}
func invalidURL(raw, reason string) error {
return apperrors.NewValidation("无法解析 AI 表格 URL",
apperrors.WithReason("invalid_aitable_url"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint(reason),
apperrors.WithDetails(map[string]any{"url": raw}),
)
}
func resolutionError(status, entityType, query string, candidates []Candidate) error {
return apperrors.NewValidation(fmt.Sprintf("%s target %s: %q", entityType, status, query),
apperrors.WithReason("target_"+status),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{
"status": status, "entityType": entityType, "query": query,
"candidates": candidates,
}),
)
}
func incomplete(entityType, query string, candidates []Candidate, cause string) error {
return apperrors.NewAPI("目标解析候选集不完整,不能安全选择",
apperrors.WithReason("target_incomplete"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithHint(cause),
apperrors.WithDetails(map[string]any{
"status": "incomplete", "entityType": entityType, "query": query,
"candidates": dedupe(candidates),
}),
)
}
func invalidResponse(entityType, query, cause string) error {
return apperrors.NewAPI("目标解析接口返回未知结构,不能把它当作未找到",
apperrors.WithReason("target_invalid_response"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint(cause),
apperrors.WithDetails(map[string]any{"entityType": entityType, "query": query}),
)
}