Files
dingtalk-workspace-cli/internal/shortcut/smart/resolve.go
DennisandClaude Opus 4.8 d41214988a fix(shortcut): keep external contacts in name resolution; alias resource-url msg-id
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>
2026-07-28 20:11:26 +08:00

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
}