1111 lines
35 KiB
Go
1111 lines
35 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.
|
|
|
|
// Package targetresolver provides deterministic natural-target resolution for
|
|
// shortcuts. It owns extraction, stable-ID de-duplication, exact-match
|
|
// preference, ambiguity handling, and the machine-readable resolution error
|
|
// envelope shared by send/read/search/create/event facades.
|
|
package targetresolver
|
|
|
|
import (
|
|
"encoding/base64"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
|
)
|
|
|
|
// Status is the stable resolution outcome used in successful plans and errors.
|
|
type Status string
|
|
|
|
const (
|
|
StatusResolved Status = "resolved"
|
|
StatusAmbiguous Status = "ambiguous"
|
|
StatusNotFound Status = "not_found"
|
|
StatusIncomplete Status = "incomplete"
|
|
)
|
|
|
|
const (
|
|
chatResolutionPageSize = 10
|
|
chatResolutionMaxWindowSize = 100
|
|
chatResolutionPageLimit = 40
|
|
userSearchDefaultResultLimit = 20
|
|
)
|
|
|
|
// IdentityRequirement filters contacts to identities accepted by the
|
|
// downstream interface.
|
|
type IdentityRequirement string
|
|
|
|
const (
|
|
IdentityAny IdentityRequirement = "any"
|
|
IdentityUserID IdentityRequirement = "user_id"
|
|
IdentityOpenDingTalkID IdentityRequirement = "open_dingtalk_id"
|
|
)
|
|
|
|
// Reader is the minimal read-only transport needed by natural target
|
|
// resolution. Shortcut RuntimeContext implements it, and native facades (for
|
|
// example event +listen-im) can provide an adapter without duplicating
|
|
// extraction or ambiguity rules.
|
|
type Reader interface {
|
|
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
|
|
}
|
|
|
|
// User is the public, credential-free identity returned by contact resolution.
|
|
type User struct {
|
|
UserID string `json:"userId,omitempty"`
|
|
OpenDingTalkID string `json:"openDingTalkId,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// UserResolution is the typed successful user-resolution result.
|
|
type UserResolution struct {
|
|
Status Status `json:"status"`
|
|
EntityType string `json:"entityType"`
|
|
Query string `json:"query"`
|
|
MatchType string `json:"matchType"`
|
|
Selected User `json:"selected"`
|
|
Profile string `json:"profile,omitempty"`
|
|
}
|
|
|
|
const matchTypeUnverifiedUserID = "unverified_user_id"
|
|
|
|
// IsUnverifiedUserIDResolution reports a mixed sender value that could not be
|
|
// classified by the directory and is therefore being tried as an exact userId.
|
|
// Consumers may use positive senderId equality, but must not claim a negative
|
|
// result is complete because the original value may instead have been a name.
|
|
func IsUnverifiedUserIDResolution(resolution UserResolution) bool {
|
|
return resolution.MatchType == matchTypeUnverifiedUserID
|
|
}
|
|
|
|
// Chat is the public identity returned by group resolution.
|
|
type Chat struct {
|
|
OpenConversationID string `json:"openConversationId"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// ChatResolution is the typed successful group-resolution result.
|
|
type ChatResolution struct {
|
|
Status Status `json:"status"`
|
|
EntityType string `json:"entityType"`
|
|
Query string `json:"query"`
|
|
MatchType string `json:"matchType"`
|
|
Selected Chat `json:"selected"`
|
|
Profile string `json:"profile,omitempty"`
|
|
}
|
|
|
|
// ResolveUser searches the current profile's directory and resolves query to
|
|
// exactly one identity accepted by the downstream interface.
|
|
func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) {
|
|
query = strings.TrimSpace(query)
|
|
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
|
"keyword": query,
|
|
})
|
|
if err != nil {
|
|
return UserResolution{}, err
|
|
}
|
|
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
|
return UserResolution{}, newIncompleteUserResolutionError(
|
|
query,
|
|
dedupeUsers(ExtractUsers(data)),
|
|
cause,
|
|
)
|
|
}
|
|
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
|
}
|
|
|
|
// ResolveUserTarget accepts the mixed user target used by public convenience
|
|
// flags: a natural name, userId, or current-version openDingTalkId. The local
|
|
// format check is deliberately a classifier rather than an existence check.
|
|
// Format-valid IDs go directly to the downstream business API; all other
|
|
// values use the directory resolver, which can match names and exact userIds.
|
|
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return UserResolution{}, apperrors.NewValidation(
|
|
"必须指定用户姓名、userId 或 openDingTalkId",
|
|
apperrors.WithReason("missing_target"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("request_validation"),
|
|
apperrors.WithExecutionStarted(false),
|
|
)
|
|
}
|
|
if !LooksLikeCurrentDOpenDingTalkID(value) {
|
|
return ResolveUser(rt, value, requirement)
|
|
}
|
|
if requirement == IdentityUserID {
|
|
return UserResolution{}, apperrors.NewValidation(
|
|
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
|
|
apperrors.WithReason("target_type_mismatch"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
)
|
|
}
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: "stable_id",
|
|
Selected: User{OpenDingTalkID: value},
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
|
|
// ResolveSenderTarget resolves the mixed identity accepted specifically by
|
|
// sender convenience flags. Unlike the shared name resolver, it may select a
|
|
// unique candidate whose stable userId/openDingTalkId exactly equals the
|
|
// supplied value. Natural-name resolution accepts only exact names and retains
|
|
// fail-closed ambiguity behavior; a lone unrelated directory candidate must
|
|
// never replace the supplied value. If the directory cannot classify a value
|
|
// and userId is accepted downstream, the original value is retained as an
|
|
// unverified userId candidate so a valid stable ID is not blocked by directory
|
|
// availability.
|
|
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
|
|
return ResolveUserTarget(rt, value, requirement)
|
|
}
|
|
|
|
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
|
"keyword": value,
|
|
})
|
|
if err != nil {
|
|
if requirement == IdentityOpenDingTalkID {
|
|
return UserResolution{}, err
|
|
}
|
|
return unverifiedSenderUserID(value), nil
|
|
}
|
|
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
|
|
stableMatches := make([]User, 0, 1)
|
|
for _, user := range users {
|
|
if strings.TrimSpace(user.UserID) == value ||
|
|
strings.TrimSpace(user.OpenDingTalkID) == value {
|
|
stableMatches = append(stableMatches, user)
|
|
}
|
|
}
|
|
if len(stableMatches) == 1 {
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: "stable_id",
|
|
Selected: stableMatches[0],
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
if len(stableMatches) > 1 {
|
|
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
|
|
}
|
|
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
|
if requirement == IdentityOpenDingTalkID {
|
|
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
|
|
}
|
|
return unverifiedSenderUserID(value), nil
|
|
}
|
|
exactNameMatches := make([]User, 0, 1)
|
|
for _, user := range users {
|
|
if strings.EqualFold(strings.TrimSpace(user.Name), value) {
|
|
exactNameMatches = append(exactNameMatches, user)
|
|
}
|
|
}
|
|
if len(exactNameMatches) == 1 {
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: "exact",
|
|
Selected: exactNameMatches[0],
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
if len(exactNameMatches) > 1 {
|
|
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, exactNameMatches)
|
|
}
|
|
if requirement == IdentityOpenDingTalkID {
|
|
return UserResolution{}, newResolutionError(StatusNotFound, "user", value, users)
|
|
}
|
|
return unverifiedSenderUserID(value), nil
|
|
}
|
|
|
|
func unverifiedSenderUserID(value string) UserResolution {
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: matchTypeUnverifiedUserID,
|
|
Selected: User{UserID: value},
|
|
Profile: profilectx.Get(),
|
|
}
|
|
}
|
|
|
|
// ResolveStableUserTarget accepts only a userId or current-version
|
|
// openDingTalkId. Because callers use it only for ID-only flags, a non-D value
|
|
// is already explicitly typed as userId and can be passed through without a
|
|
// directory availability dependency.
|
|
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return UserResolution{}, apperrors.NewValidation(
|
|
"必须指定 userId 或 openDingTalkId",
|
|
apperrors.WithReason("missing_target"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("request_validation"),
|
|
apperrors.WithExecutionStarted(false),
|
|
)
|
|
}
|
|
if LooksLikeCurrentDOpenDingTalkID(value) {
|
|
if requirement == IdentityUserID {
|
|
return UserResolution{}, apperrors.NewValidation(
|
|
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
|
|
apperrors.WithReason("target_type_mismatch"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
)
|
|
}
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: "stable_id",
|
|
Selected: User{OpenDingTalkID: value},
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
if requirement == IdentityOpenDingTalkID {
|
|
return UserResolution{}, apperrors.NewValidation(
|
|
fmt.Sprintf("用户目标参数类型不匹配:%q 是 userId,但当前参数只接受 openDingTalkId", value),
|
|
apperrors.WithReason("target_type_mismatch"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
)
|
|
}
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: value,
|
|
MatchType: "stable_id",
|
|
Selected: User{UserID: value},
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
|
|
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
|
|
// wire format emitted by the current D-version encoder. It does not decrypt
|
|
// the value and therefore does not prove that the identity exists or is
|
|
// accessible to the current profile.
|
|
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) < 2 || value[0] != 'D' {
|
|
return false
|
|
}
|
|
|
|
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
|
|
if !ok {
|
|
return false
|
|
}
|
|
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
|
|
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
|
|
return false
|
|
}
|
|
|
|
// Require the canonical Base64 and escape spelling. This rejects alternate
|
|
// but decodable strings and keeps the classifier aligned with the encoder.
|
|
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
|
|
return canonical == value[1:]
|
|
}
|
|
|
|
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
|
|
var decoded strings.Builder
|
|
decoded.Grow(len(value))
|
|
for i := 0; i < len(value); i++ {
|
|
current := value[i]
|
|
if current != 'i' {
|
|
if !isASCIIAlphaNumeric(current) {
|
|
return "", false
|
|
}
|
|
decoded.WriteByte(current)
|
|
continue
|
|
}
|
|
if i+1 >= len(value) {
|
|
return "", false
|
|
}
|
|
i++
|
|
switch value[i] {
|
|
case 'i':
|
|
decoded.WriteByte('i')
|
|
case 'P':
|
|
decoded.WriteByte('+')
|
|
case 'S':
|
|
decoded.WriteByte('/')
|
|
case 'E':
|
|
decoded.WriteByte('=')
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
return decoded.String(), true
|
|
}
|
|
|
|
func escapeCurrentDOpenDingTalkID(value string) string {
|
|
var encoded strings.Builder
|
|
encoded.Grow(len(value))
|
|
for i := 0; i < len(value); i++ {
|
|
switch value[i] {
|
|
case 'i':
|
|
encoded.WriteString("ii")
|
|
case '+':
|
|
encoded.WriteString("iP")
|
|
case '/':
|
|
encoded.WriteString("iS")
|
|
case '=':
|
|
encoded.WriteString("iE")
|
|
default:
|
|
encoded.WriteByte(value[i])
|
|
}
|
|
}
|
|
return encoded.String()
|
|
}
|
|
|
|
func isASCIIAlphaNumeric(value byte) bool {
|
|
return value >= '0' && value <= '9' ||
|
|
value >= 'A' && value <= 'Z' ||
|
|
value >= 'a' && value <= 'z'
|
|
}
|
|
|
|
// ValidateExplicitOpenDingTalkID applies the current-version format contract
|
|
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
|
|
// must fail locally instead of falling back to a name or userId lookup.
|
|
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
|
|
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
|
|
return nil
|
|
}
|
|
return apperrors.NewValidation(
|
|
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
|
|
apperrors.WithReason("target_type_mismatch"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("request_validation"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
|
|
apperrors.WithDetails(map[string]any{
|
|
"flag": flagName,
|
|
"expectedType": "current_d_openDingTalkId",
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ResolveEnterpriseUser resolves an organization member through the same
|
|
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
|
|
// contact search, this source understands organization nicknames and aliases.
|
|
func ResolveEnterpriseUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) {
|
|
query = strings.TrimSpace(query)
|
|
data, err := rt.CallMCPData("aisearch", "enterprise_person_search", map[string]any{
|
|
"keyword": query,
|
|
"dimension": []string{"name"},
|
|
})
|
|
if err != nil {
|
|
return UserResolution{}, err
|
|
}
|
|
if cause := incompleteUserSearchCause(data, "企业人员搜索"); cause != "" {
|
|
return UserResolution{}, newIncompleteUserResolutionError(
|
|
query,
|
|
dedupeUsers(ExtractUsers(data)),
|
|
cause,
|
|
)
|
|
}
|
|
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
|
}
|
|
|
|
// incompleteUserSearchCause enforces completeness for the two public user
|
|
// search RPCs. Their discovered input contracts do not accept any cursor or
|
|
// page parameter, while search_contact_by_key_word is observed to cap an
|
|
// unqualified result at 20. An explicit terminal hasMore=false proves the page
|
|
// complete; otherwise a continuation marker or a full default page must fail
|
|
// closed instead of treating a first-page match as globally unique.
|
|
func incompleteUserSearchCause(data map[string]any, source string) string {
|
|
page := extractChatPagination(data)
|
|
if page.hasMoreKnown {
|
|
if page.hasMore {
|
|
return source + "返回了 hasMore=true,但公开接口不接受游标参数,无法安全读取后续候选"
|
|
}
|
|
return ""
|
|
}
|
|
if page.nextCursor != "" {
|
|
return source + "返回了后续游标,但公开接口不接受游标参数,无法安全读取后续候选"
|
|
}
|
|
if len(firstList(data, "result", "items", "users", "list")) >= userSearchDefaultResultLimit {
|
|
return fmt.Sprintf("%s返回了满额 %d 条候选且没有结束标记,无法证明后续不存在同名用户", source, userSearchDefaultResultLimit)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveUserCandidates(candidates []User, query string, requirement IdentityRequirement) (UserResolution, error) {
|
|
users := filterUsersByIdentity(dedupeUsers(candidates), requirement)
|
|
selected, matchType := selectUsers(users, query)
|
|
if len(selected) == 0 {
|
|
return UserResolution{}, newResolutionError(StatusNotFound, "user", query, users)
|
|
}
|
|
if len(selected) > 1 {
|
|
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", query, selected)
|
|
}
|
|
return UserResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "user",
|
|
Query: query,
|
|
MatchType: matchType,
|
|
Selected: selected[0],
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
|
|
// ResolveChat searches groups and resolves query to exactly one stable
|
|
// openConversationId. Exact names win over substring matches, but multiple
|
|
// exact names remain ambiguous.
|
|
func ResolveChat(rt Reader, query string) (ChatResolution, error) {
|
|
query = strings.TrimSpace(query)
|
|
if LooksLikeOpenConversationID(query) {
|
|
return ChatResolution{}, apperrors.NewValidation(fmt.Sprintf(
|
|
"群目标参数类型不匹配:%q 符合 openConversationId 格式,当前参数仅接受群名称",
|
|
query,
|
|
),
|
|
apperrors.WithReason("target_type_mismatch"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithHint("请使用当前命令的稳定 ID 参数;运行当前 leaf --help 可查看准确参数名。"),
|
|
apperrors.WithDetails(map[string]any{
|
|
"type": "resolution",
|
|
"entityType": "chat",
|
|
"providedType": "openConversationId",
|
|
"expectedType": "chatName",
|
|
}),
|
|
)
|
|
}
|
|
cursor := "0"
|
|
requestPageSize := chatResolutionPageSize
|
|
maxWindowProbeUsed := false
|
|
seenCursors := map[string]bool{cursor: true}
|
|
chats := make([]Chat, 0)
|
|
complete := false
|
|
for pageNumber := 1; pageNumber <= chatResolutionPageLimit; pageNumber++ {
|
|
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
|
|
"keyword": query,
|
|
"limit": requestPageSize,
|
|
"cursor": cursor,
|
|
})
|
|
if err != nil {
|
|
return ChatResolution{}, err
|
|
}
|
|
rows := firstList(data, "result", "items", "groups", "list")
|
|
chats = append(chats, ExtractChats(data)...)
|
|
page := extractChatPagination(data)
|
|
switch {
|
|
case page.hasMoreKnown && !page.hasMore:
|
|
if len(rows) >= requestPageSize && page.nextCursor == "" &&
|
|
(pageNumber == 1 || (maxWindowProbeUsed && pageNumber == 2)) {
|
|
if cursor == "0" && !maxWindowProbeUsed && requestPageSize < chatResolutionMaxWindowSize && pageNumber < chatResolutionPageLimit {
|
|
requestPageSize = chatResolutionMaxWindowSize
|
|
maxWindowProbeUsed = true
|
|
continue
|
|
}
|
|
return ChatResolution{}, newIncompleteChatResolutionError(
|
|
query,
|
|
dedupeChats(chats),
|
|
"群搜索返回满页并声明 hasMore=false,但没有可用 nextCursor,无法证明候选完整",
|
|
)
|
|
}
|
|
complete = true
|
|
case page.hasMoreKnown && page.hasMore:
|
|
// A usable cursor is required below.
|
|
case page.nextCursor != "":
|
|
// Some versions omit hasMore but still publish a continuation cursor.
|
|
case len(rows) < requestPageSize:
|
|
// Compatibility for older responses that return a short bare array
|
|
// without pagination metadata.
|
|
complete = true
|
|
default:
|
|
return ChatResolution{}, newIncompleteChatResolutionError(
|
|
query,
|
|
dedupeChats(chats),
|
|
"群搜索返回了满页结果,但没有 hasMore 或 nextCursor,无法证明候选已完整读取",
|
|
)
|
|
}
|
|
if complete {
|
|
break
|
|
}
|
|
nextCursor := strings.TrimSpace(page.nextCursor)
|
|
if nextCursor == "" || seenCursors[nextCursor] {
|
|
reason := "群搜索声明仍有更多结果,但没有返回可继续的 nextCursor"
|
|
if nextCursor != "" {
|
|
reason = fmt.Sprintf("群搜索分页游标停滞在 %q,无法证明候选已完整读取", nextCursor)
|
|
}
|
|
return ChatResolution{}, newIncompleteChatResolutionError(
|
|
query,
|
|
dedupeChats(chats),
|
|
reason,
|
|
)
|
|
}
|
|
seenCursors[nextCursor] = true
|
|
cursor = nextCursor
|
|
}
|
|
chats = dedupeChats(chats)
|
|
if !complete {
|
|
return ChatResolution{}, newIncompleteChatResolutionError(
|
|
query,
|
|
chats,
|
|
fmt.Sprintf("群搜索达到安全页数上限 %d,仍无法证明候选已完整读取", chatResolutionPageLimit),
|
|
)
|
|
}
|
|
selected, matchType := preferExactChats(chats, query)
|
|
if len(selected) == 0 {
|
|
return ChatResolution{}, newResolutionError(StatusNotFound, "chat", query, chats)
|
|
}
|
|
if len(selected) > 1 {
|
|
return ChatResolution{}, newResolutionError(StatusAmbiguous, "chat", query, selected)
|
|
}
|
|
return ChatResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "chat",
|
|
Query: query,
|
|
MatchType: matchType,
|
|
Selected: selected[0],
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
|
|
// ResolveChatTarget accepts either a stable openConversationId or a natural
|
|
// group name and returns one normalized ChatResolution. Stable IDs never reach
|
|
// search_groups; natural names retain ResolveChat's complete-pagination and
|
|
// fail-closed ambiguity semantics.
|
|
func ResolveChatTarget(rt Reader, directValue, queryValue string) (ChatResolution, error) {
|
|
directValue = strings.TrimSpace(directValue)
|
|
queryValue = strings.TrimSpace(queryValue)
|
|
if directValue != "" && queryValue != "" {
|
|
return ChatResolution{}, apperrors.NewValidation(
|
|
"稳定群目标与群名查询参数不能同时指定",
|
|
apperrors.WithReason("target_arguments_conflict"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("request_validation"),
|
|
apperrors.WithExecutionStarted(false),
|
|
)
|
|
}
|
|
value := directValue
|
|
if value == "" {
|
|
value = queryValue
|
|
}
|
|
if value == "" {
|
|
return ChatResolution{}, apperrors.NewValidation(
|
|
"必须指定群名或 openConversationId",
|
|
apperrors.WithReason("missing_target"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("request_validation"),
|
|
apperrors.WithExecutionStarted(false),
|
|
)
|
|
}
|
|
if LooksLikeOpenConversationID(value) {
|
|
return ChatResolution{
|
|
Status: StatusResolved,
|
|
EntityType: "chat",
|
|
Query: value,
|
|
MatchType: "stable_id",
|
|
Selected: Chat{OpenConversationID: value},
|
|
Profile: profilectx.Get(),
|
|
}, nil
|
|
}
|
|
return ResolveChat(rt, value)
|
|
}
|
|
|
|
// LooksLikeOpenConversationID identifies the opaque group-conversation IDs
|
|
// returned by DingTalk. It is deliberately conservative: only the stable
|
|
// "cid..." form is recognized, so ordinary short names are still resolved by
|
|
// search and ambiguity checks.
|
|
func LooksLikeOpenConversationID(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return len(value) >= 12 && strings.HasPrefix(strings.ToLower(value), "cid")
|
|
}
|
|
|
|
type chatPagination struct {
|
|
hasMore bool
|
|
hasMoreKnown bool
|
|
nextCursor string
|
|
}
|
|
|
|
func extractChatPagination(data map[string]any) chatPagination {
|
|
if data == nil {
|
|
return chatPagination{}
|
|
}
|
|
scopes := []map[string]any{data}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if nested, ok := data[wrapper].(map[string]any); ok {
|
|
scopes = append(scopes, nested)
|
|
}
|
|
}
|
|
page := chatPagination{}
|
|
for _, scope := range scopes {
|
|
if !page.hasMoreKnown {
|
|
for _, key := range []string{"hasMore", "has_more"} {
|
|
if value, ok := scope[key].(bool); ok {
|
|
page.hasMore = value
|
|
page.hasMoreKnown = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if page.nextCursor == "" {
|
|
for _, key := range []string{"nextCursor", "next_cursor", "nextToken", "next_token"} {
|
|
if value, ok := scope[key]; ok {
|
|
page.nextCursor = paginationString(value)
|
|
if page.nextCursor != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return page
|
|
}
|
|
|
|
func paginationString(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
if text == "" || text == "<nil>" {
|
|
return ""
|
|
}
|
|
return text
|
|
}
|
|
|
|
// ResolveUsers resolves every query before returning. Resolution failures are
|
|
// collected into one typed envelope; upstream/auth failures still stop
|
|
// immediately. Successful identities are de-duplicated by stable ID.
|
|
func ResolveUsers(
|
|
rt Reader,
|
|
queries []string,
|
|
requirement IdentityRequirement,
|
|
) ([]UserResolution, error) {
|
|
results := make([]UserResolution, 0, len(queries))
|
|
failures := make([]map[string]any, 0)
|
|
for _, query := range uniqueQueries(queries) {
|
|
resolved, err := ResolveUser(rt, query, requirement)
|
|
if err != nil {
|
|
if details, ok := resolutionDetails(err); ok {
|
|
failures = append(failures, details)
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
results = append(results, resolved)
|
|
}
|
|
if len(failures) > 0 {
|
|
return nil, batchResolutionError("user", failures)
|
|
}
|
|
seenUserIDs := map[string]bool{}
|
|
seenOpenIDs := map[string]bool{}
|
|
deduped := make([]UserResolution, 0, len(results))
|
|
for _, result := range results {
|
|
user := result.Selected
|
|
if (user.UserID != "" && seenUserIDs[user.UserID]) ||
|
|
(user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) {
|
|
continue
|
|
}
|
|
seenUserIDs[user.UserID] = user.UserID != ""
|
|
seenOpenIDs[user.OpenDingTalkID] = user.OpenDingTalkID != ""
|
|
deduped = append(deduped, result)
|
|
}
|
|
return deduped, nil
|
|
}
|
|
|
|
// ResolveChats is the batch equivalent of ResolveChat and returns only after
|
|
// every query has been preflighted.
|
|
func ResolveChats(rt Reader, queries []string) ([]ChatResolution, error) {
|
|
results := make([]ChatResolution, 0, len(queries))
|
|
failures := make([]map[string]any, 0)
|
|
for _, query := range uniqueQueries(queries) {
|
|
resolved, err := ResolveChat(rt, query)
|
|
if err != nil {
|
|
if details, ok := resolutionDetails(err); ok {
|
|
failures = append(failures, details)
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
results = append(results, resolved)
|
|
}
|
|
seen := map[string]bool{}
|
|
deduped := make([]ChatResolution, 0, len(results))
|
|
for _, result := range results {
|
|
if seen[result.Selected.OpenConversationID] {
|
|
continue
|
|
}
|
|
seen[result.Selected.OpenConversationID] = true
|
|
deduped = append(deduped, result)
|
|
}
|
|
if len(failures) > 0 {
|
|
return nil, batchResolutionError("chat", failures)
|
|
}
|
|
return deduped, nil
|
|
}
|
|
|
|
// ExtractUsers accepts the contact search response shapes used by the current
|
|
// directory MCP and keeps external contacts that only expose openDingTalkId.
|
|
func ExtractUsers(data map[string]any) []User {
|
|
raw := firstList(data, "result", "items", "users", "list")
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
users := make([]User, 0, len(raw))
|
|
for _, item := range raw {
|
|
row, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
userID := stringValue(row, "userId", "orgUserId")
|
|
openID := stringValue(row, "openDingTalkId", "openDingtalkId")
|
|
name := stringValue(row,
|
|
"name", "nick", "showName", "flowerName", "staffName", "userName", "author", "title")
|
|
if meta, ok := row["meta"].(map[string]any); ok {
|
|
if userID == "" {
|
|
userID = stringValue(meta, "userId", "orgUserId", "staffId")
|
|
}
|
|
if openID == "" {
|
|
openID = stringValue(meta, "openDingTalkId", "openDingtalkId")
|
|
}
|
|
if metaName := stringValue(meta, "name", "nick"); metaName != "" {
|
|
name = metaName
|
|
}
|
|
}
|
|
if userID == "" && openID == "" {
|
|
continue
|
|
}
|
|
users = append(users, User{
|
|
UserID: userID,
|
|
OpenDingTalkID: openID,
|
|
Name: name,
|
|
})
|
|
}
|
|
return users
|
|
}
|
|
|
|
// ExtractChats accepts both bare and wrapped group search result lists.
|
|
func ExtractChats(data map[string]any) []Chat {
|
|
raw := firstList(data, "result", "items", "groups", "list")
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
chats := make([]Chat, 0, len(raw))
|
|
for _, item := range raw {
|
|
row, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := stringValue(row, "openConversationId", "openconversationId", "id")
|
|
if id == "" {
|
|
continue
|
|
}
|
|
chats = append(chats, Chat{
|
|
OpenConversationID: id,
|
|
Name: stringValue(row, "title", "name", "conversationName"),
|
|
})
|
|
}
|
|
return chats
|
|
}
|
|
|
|
// UsersWithUserID preserves the legacy smart-shortcut helper contract.
|
|
func UsersWithUserID(users []User) []User {
|
|
return filterUsersByIdentity(users, IdentityUserID)
|
|
}
|
|
|
|
// PreferExactChats exposes the deterministic selection helper to legacy smart
|
|
// shortcuts while they migrate to ResolveChat.
|
|
func PreferExactChats(chats []Chat, query string) []Chat {
|
|
selected, _ := preferExactChats(dedupeChats(chats), query)
|
|
return selected
|
|
}
|
|
|
|
// UserLabels renders credential-free disambiguation labels.
|
|
func UserLabels(users []User) []string {
|
|
labels := make([]string, 0, len(users))
|
|
for _, user := range users {
|
|
id := user.UserID
|
|
if id == "" {
|
|
id = user.OpenDingTalkID
|
|
}
|
|
labels = append(labels, fmt.Sprintf("%s(%s)", user.Name, id))
|
|
}
|
|
return labels
|
|
}
|
|
|
|
// ChatLabels renders group disambiguation labels.
|
|
func ChatLabels(chats []Chat) []string {
|
|
labels := make([]string, 0, len(chats))
|
|
for _, chat := range chats {
|
|
name := chat.Name
|
|
if name == "" {
|
|
name = "(未命名群)"
|
|
}
|
|
labels = append(labels, fmt.Sprintf("%s(%s)", name, chat.OpenConversationID))
|
|
}
|
|
return labels
|
|
}
|
|
|
|
func selectUsers(users []User, query string) ([]User, string) {
|
|
if len(users) != 1 {
|
|
return users, "ambiguous"
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(users[0].Name), strings.TrimSpace(query)) {
|
|
return users, "exact"
|
|
}
|
|
return users, "unique"
|
|
}
|
|
|
|
func preferExactChats(chats []Chat, query string) ([]Chat, string) {
|
|
exact := make([]Chat, 0, len(chats))
|
|
for _, chat := range chats {
|
|
if strings.EqualFold(strings.TrimSpace(chat.Name), strings.TrimSpace(query)) {
|
|
exact = append(exact, chat)
|
|
}
|
|
}
|
|
if len(exact) > 0 {
|
|
return exact, "exact"
|
|
}
|
|
return chats, "unique"
|
|
}
|
|
|
|
func filterUsersByIdentity(users []User, requirement IdentityRequirement) []User {
|
|
filtered := make([]User, 0, len(users))
|
|
for _, user := range users {
|
|
switch requirement {
|
|
case IdentityUserID:
|
|
if user.UserID == "" {
|
|
continue
|
|
}
|
|
case IdentityOpenDingTalkID:
|
|
if user.OpenDingTalkID == "" {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, user)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func dedupeUsers(users []User) []User {
|
|
result := make([]User, 0, len(users))
|
|
seenUserIDs := map[string]bool{}
|
|
seenOpenIDs := map[string]bool{}
|
|
for _, user := range users {
|
|
if (user.UserID != "" && seenUserIDs[user.UserID]) ||
|
|
(user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) {
|
|
continue
|
|
}
|
|
if user.UserID != "" {
|
|
seenUserIDs[user.UserID] = true
|
|
}
|
|
if user.OpenDingTalkID != "" {
|
|
seenOpenIDs[user.OpenDingTalkID] = true
|
|
}
|
|
result = append(result, user)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func dedupeChats(chats []Chat) []Chat {
|
|
result := make([]Chat, 0, len(chats))
|
|
seen := map[string]bool{}
|
|
for _, chat := range chats {
|
|
if seen[chat.OpenConversationID] {
|
|
continue
|
|
}
|
|
seen[chat.OpenConversationID] = true
|
|
result = append(result, chat)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func newResolutionError(status Status, entityType, query string, candidates any) error {
|
|
details := map[string]any{
|
|
"type": "resolution",
|
|
"subtype": status,
|
|
"entityType": entityType,
|
|
"query": query,
|
|
"candidates": candidates,
|
|
}
|
|
if profile := profilectx.Get(); profile != "" {
|
|
details["profile"] = profile
|
|
}
|
|
if status == StatusNotFound {
|
|
return apperrors.NewValidation(
|
|
fmt.Sprintf("没有找到与 %q 唯一匹配且可用于当前操作的%s", query, entityLabel(entityType)),
|
|
apperrors.WithReason("resolution_not_found"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithHint("请提供更完整的名称或直接传稳定 ID"),
|
|
apperrors.WithDetails(details),
|
|
)
|
|
}
|
|
return apperrors.NewValidation(
|
|
fmt.Sprintf("%q 匹配到多个%s:%s;请提供更精确的名称或直接传稳定 ID", query, entityLabel(entityType), candidateLabels(candidates)),
|
|
apperrors.WithReason("resolution_ambiguous"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithHint("禁止默认选择第一个候选"),
|
|
apperrors.WithDetails(details),
|
|
)
|
|
}
|
|
|
|
func newIncompleteChatResolutionError(query string, candidates []Chat, cause string) error {
|
|
details := map[string]any{
|
|
"type": "resolution",
|
|
"subtype": StatusIncomplete,
|
|
"entityType": "chat",
|
|
"query": query,
|
|
"candidates": candidates,
|
|
"cause": cause,
|
|
}
|
|
if profile := profilectx.Get(); profile != "" {
|
|
details["profile"] = profile
|
|
}
|
|
return apperrors.NewAPI(
|
|
fmt.Sprintf("群名 %q 的候选未能完整读取,已停止后续操作", query),
|
|
apperrors.WithReason("resolution_incomplete"),
|
|
apperrors.WithOrigin("mcp_gateway"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(true),
|
|
apperrors.WithHint("请重试,或直接传 openConversationId 跳过群名解析"),
|
|
apperrors.WithDetails(details),
|
|
)
|
|
}
|
|
|
|
func newIncompleteUserResolutionError(query string, candidates []User, cause string) error {
|
|
details := map[string]any{
|
|
"type": "resolution",
|
|
"subtype": StatusIncomplete,
|
|
"entityType": "user",
|
|
"query": query,
|
|
"candidates": candidates,
|
|
"cause": cause,
|
|
}
|
|
if profile := profilectx.Get(); profile != "" {
|
|
details["profile"] = profile
|
|
}
|
|
return apperrors.NewAPI(
|
|
fmt.Sprintf("用户 %q 的候选未能完整读取,已停止后续操作", query),
|
|
apperrors.WithReason("resolution_incomplete"),
|
|
apperrors.WithOrigin("mcp_gateway"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(true),
|
|
apperrors.WithHint("请重试,或直接传 userId/openDingTalkId 跳过姓名解析"),
|
|
apperrors.WithDetails(details),
|
|
)
|
|
}
|
|
|
|
func batchResolutionError(entityType string, failures []map[string]any) error {
|
|
return apperrors.NewValidation(
|
|
fmt.Sprintf("%d 个%s目标未能唯一解析;已停止后续操作", len(failures), entityLabel(entityType)),
|
|
apperrors.WithReason("resolution_batch_failed"),
|
|
apperrors.WithOrigin("client"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithHint("请逐项消歧或直接传稳定 ID"),
|
|
apperrors.WithDetails(map[string]any{
|
|
"type": "resolution",
|
|
"subtype": "batch_failed",
|
|
"entityType": entityType,
|
|
"failures": failures,
|
|
}),
|
|
)
|
|
}
|
|
|
|
func resolutionDetails(err error) (map[string]any, bool) {
|
|
var typed *apperrors.Error
|
|
if !stderrors.As(err, &typed) || typed.Details["type"] != "resolution" {
|
|
return nil, false
|
|
}
|
|
switch typed.Reason {
|
|
case "resolution_not_found", "resolution_ambiguous":
|
|
// These are client-side disambiguation outcomes and may be combined
|
|
// into one batch response. Incomplete/API/auth failures must retain
|
|
// their original category and retryability and stop immediately.
|
|
default:
|
|
return nil, false
|
|
}
|
|
return typed.Details, true
|
|
}
|
|
|
|
func entityLabel(entityType string) string {
|
|
if entityType == "chat" {
|
|
return "群聊"
|
|
}
|
|
return "用户"
|
|
}
|
|
|
|
func candidateLabels(candidates any) string {
|
|
switch values := candidates.(type) {
|
|
case []User:
|
|
return strings.Join(UserLabels(values), "、")
|
|
case []Chat:
|
|
return strings.Join(ChatLabels(values), "、")
|
|
default:
|
|
return "候选"
|
|
}
|
|
}
|
|
|
|
func firstList(data map[string]any, keys ...string) []any {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
scopes := []map[string]any{data}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if nested, ok := data[wrapper].(map[string]any); ok {
|
|
scopes = append(scopes, nested)
|
|
}
|
|
}
|
|
for _, scope := range scopes {
|
|
for _, key := range keys {
|
|
if raw, ok := scope[key].([]any); ok {
|
|
return raw
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringValue(row map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := row[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func uniqueQueries(queries []string) []string {
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0, len(queries))
|
|
for _, query := range queries {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" || seen[query] {
|
|
continue
|
|
}
|
|
seen[query] = true
|
|
result = append(result, query)
|
|
}
|
|
return result
|
|
}
|