Two independent shortcut correctness fixes surfaced by the audit: - Name→ID resolution (chat +dm / +broadcast / … via the shared resolver) dropped every search_contact_by_key_word row with an empty userId. External / cross-org contacts arrive with only an openDingTalkId, so they were silently discarded — making resolution report a real person as missing, or collapse to the wrong single match when an in-org namesake existed. Keep any row with at least one usable identity (userId or openDingTalkId) and fall the display name back through nick/showName/flowerName/staffName/userName. - chat +messages-resource-url required --message-id with no alias, so an agent copying the message list's openMessageId/msgId output field hit "unknown flag". Accept --msg-id / --open-message-id as aliases (declared via an at-least-one constraint since a shortcut's Required check only sees the primary flag name), mirroring the earlier chat message download-media fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
4.2 KiB
Go
132 lines
4.2 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 (
|
|
"fmt"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
)
|
|
|
|
// contactUser is the minimal identity a smart shortcut needs after resolving a
|
|
// name.
|
|
type contactUser struct {
|
|
userID string
|
|
openDingTalkID string
|
|
name string
|
|
}
|
|
|
|
// resolveUser turns a human name into a single unique contact, the "name → ID"
|
|
// intelligence that distinguishes a smart shortcut from a raw tool call
|
|
// (mirrors the shared name→ID resolver). It searches the directory and:
|
|
// - errors with a clear message if nobody matches;
|
|
// - errors listing the candidates if the name is ambiguous (never guesses).
|
|
func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
|
return resolveUserByName(rt, name, false)
|
|
}
|
|
|
|
// resolveOpenDingTalkUser also accepts external / cross-org contacts that have
|
|
// an openDingTalkId but no organization-scoped userId. Use it only for flows
|
|
// whose downstream interface consumes openDingTalkId.
|
|
func resolveOpenDingTalkUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
|
return resolveUserByName(rt, name, true)
|
|
}
|
|
|
|
func resolveUserByName(rt *shortcut.RuntimeContext, name string, includeOpenIDOnly bool) (contactUser, error) {
|
|
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
|
"keyword": name,
|
|
})
|
|
if err != nil {
|
|
return contactUser{}, err
|
|
}
|
|
users := extractUsers(data)
|
|
if !includeOpenIDOnly {
|
|
users = usersWithUserID(users)
|
|
}
|
|
switch {
|
|
case len(users) == 0:
|
|
return contactUser{}, apperrors.NewValidation(
|
|
fmt.Sprintf("通讯录里没找到叫 %q 的人;换个更完整的姓名再试。", name))
|
|
case len(users) > 1:
|
|
return contactUser{}, apperrors.NewValidation(fmt.Sprintf(
|
|
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或改用对应命令直接传用户 ID。",
|
|
name, len(users), strings.Join(userLabels(users), "、")))
|
|
}
|
|
return users[0], nil
|
|
}
|
|
|
|
func usersWithUserID(users []contactUser) []contactUser {
|
|
out := make([]contactUser, 0, len(users))
|
|
for _, user := range users {
|
|
if user.userID != "" {
|
|
out = append(out, user)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractUsers pulls {userId, openDingTalkId, name} out of a
|
|
// search_contact_by_key_word response ({"result": [ {userId, name, ...} ]}).
|
|
func extractUsers(data map[string]any) []contactUser {
|
|
raw, ok := data["result"].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var out []contactUser
|
|
for _, item := range raw {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := m["userId"].(string)
|
|
openID, _ := m["openDingTalkId"].(string)
|
|
if openID == "" {
|
|
openID, _ = m["openDingtalkId"].(string)
|
|
}
|
|
// External / cross-org contacts come back with an empty userId and only
|
|
// an openDingTalkId (verified live). Dropping them here made name→ID
|
|
// resolution miss those people, or collapse to the wrong single match
|
|
// when an in-org namesake also existed. Keep any row with at least one
|
|
// usable identity; downstream (e.g. +dm) can act on the openDingTalkId.
|
|
if id == "" && openID == "" {
|
|
continue
|
|
}
|
|
nm, _ := m["name"].(string)
|
|
if nm == "" {
|
|
for _, k := range []string{"nick", "showName", "flowerName", "staffName", "userName"} {
|
|
if v, _ := m[k].(string); v != "" {
|
|
nm = v
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out = append(out, contactUser{userID: id, openDingTalkID: openID, name: nm})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func userLabels(users []contactUser) []string {
|
|
out := make([]string, 0, len(users))
|
|
for _, u := range users {
|
|
id := u.userID
|
|
if id == "" {
|
|
id = u.openDingTalkID
|
|
}
|
|
out = append(out, fmt.Sprintf("%s(%s)", u.name, id))
|
|
}
|
|
return out
|
|
}
|