442 lines
14 KiB
Go
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}),
|
|
)
|
|
}
|