Files
dingtalk-workspace-cli/internal/shortcut/smart/group_members.go

488 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
groupMembersDefaultPageLimit = 50
groupMembersHardPageLimit = 500
groupMembersContractVersion = "im.group-members.v1"
)
// GroupMembers: list a group's members by its NAME, no openConversationId juggling.
//
// Steps: search groups by name → resolve to a single openConversationId
// (disambiguate on multiple matches, never guess) → list that group's members.
// Replaces `chat search --query <群名>` (copy openConversationId) →
// `chat group members --id <openConversationId>`.
//
// Note: the group lookup uses `search_groups` (im server, keyword search over
// group NAMES) — NOT `search_common_groups`, which searches by member
// nicknames and cannot locate a group by its title.
//
// dws chat +group-members --group 项目冲刺
var GroupMembers = shortcut.Shortcut{
Service: "chat",
Command: "+group-members",
Product: "chat",
Description: "按群名唯一解析后全量列出用户成员并公开分页完整性",
Intent: "当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;" +
"内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。" +
"群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "shortcut_group_members",
CanonicalPath: "chat.shortcut_group_members",
CLIPath: "chat +group-members",
PrimaryCLIPath: "chat +group-members",
},
Description: "按群名唯一解析后全量列出用户成员并公开分页完整性",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按群名唯一解析后全量列出用户成员并公开分页完整性",
UseWhen: []string{"当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +group-members --group 项目冲刺"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "最大用户成员页数;--page-limit 必须在 1-500 之间"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"},
},
Tips: []string{`dws chat +group-members --group 项目冲刺`},
Validate: validateGroupMembersPageLimit,
Execute: func(rt *shortcut.RuntimeContext) error {
resolved, err := targetresolver.ResolveChat(rt, rt.Str("group"))
if err != nil {
return err
}
result, err := collectGroupUserMembers(rt, resolved.Selected.OpenConversationID, rt.Int("page-limit"))
if err != nil {
return err
}
return rt.Output(result.payload(resolved.Selected.OpenConversationID))
},
}
// ChatMembersList aligns the recent lark-cli IM member-list experience with
// DingTalk's two lower tools: user members come from get_group_members and bots
// come from list_group_bots. Callers may provide a stable conversation ID or a
// human group name; name resolution remains ambiguity-safe.
var ChatMembersList = shortcut.Shortcut{
Service: "chat",
Command: "+chat-members-list",
Aliases: []string{"+chat-group-members"},
Product: "chat",
Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
Intent: "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" +
"--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" +
"默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" +
"用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "shortcut_chat_members_list",
CanonicalPath: "chat.shortcut_chat_members_list",
CLIPath: "chat +chat-members-list",
PrimaryCLIPath: "chat +chat-members-list",
Aliases: []string{"chat +chat-group-members"},
},
Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite member adapter: the executable CLI safely resolves a group, paginates and deduplicates user members, lists bots, and publishes per-bucket completeness and failures.",
},
Selection: contract.SelectionSpec{
AgentSummary: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
UseWhen: []string{"当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" +
"--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" +
"默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" +
"用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。"},
AvoidWhen: []string{"只需要用户成员且已有群名时可使用 +group-members;需要原始单页响应时使用底层原子命令"},
Examples: []string{
"dws chat +chat-members-list --group \"项目冲刺\"",
"dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot",
},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "显式群 openConversationId"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一 openConversationId"},
{Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型;--member-types 仅接受 user/bot;不传则同时返回"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "用户成员桶最大页数;--page-limit 必须在 1-500 之间"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "chat-query", "chat", "open-conversation-id"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-types"}, Description: "--member-types 仅接受 user/bot"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"},
},
Tips: []string{
`dws chat +chat-members-list --group "项目冲刺"`,
`dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot`,
},
Validate: validateGroupMembersPageLimit,
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "chat", "open-conversation-id"))
if groupID == "" {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
if err != nil {
return err
}
groupID = resolved.Selected.OpenConversationID
}
wantUsers, wantBots, err := resolveMemberTypes(rt.StrSlice("member-types"))
if err != nil {
return err
}
payload := map[string]any{
"contractVersion": groupMembersContractVersion,
"conversationId": groupID,
"users": []map[string]any{},
"bots": []map[string]any{},
}
buckets := map[string]any{}
var userErr, botErr error
var userResult groupUserMembersResult
if wantUsers {
userResult, userErr = collectGroupUserMembers(rt, groupID, rt.Int("page-limit"))
if userErr == nil {
payload["users"] = userResult.members
buckets["users"] = userResult.bucketPayload()
}
}
if wantBots {
var data map[string]any
data, botErr = rt.CallMCPData("bot", "list_group_bots", map[string]any{
"openConversationId": groupID,
})
if botErr == nil {
bots := groupBotProject(data)
payload["bots"] = bots
buckets["bots"] = map[string]any{
"complete": true,
"count": len(bots),
}
}
}
if wantUsers && userErr != nil && !wantBots {
return userErr
}
if wantBots && botErr != nil && !wantUsers {
return botErr
}
if userErr != nil && botErr != nil {
return fmt.Errorf("读取用户成员失败: %v;读取机器人失败: %v", userErr, botErr)
}
failures := make([]map[string]any, 0, 1+len(userResult.failures))
failures = append(failures, userResult.failures...)
if userErr != nil {
failures = append(failures, map[string]any{"bucket": "users", "stage": "read", "error": userErr.Error()})
}
if botErr != nil {
failures = append(failures, map[string]any{"bucket": "bots", "stage": "read", "error": botErr.Error()})
}
if len(failures) > 0 {
payload["errors"] = failures
payload["partial"] = true
} else {
payload["partial"] = false
}
users, _ := payload["users"].([]map[string]any)
bots, _ := payload["bots"].([]map[string]any)
payload["counts"] = map[string]any{
"users": len(users),
"bots": len(bots),
"total": len(users) + len(bots),
}
payload["buckets"] = buckets
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["complete"] = len(failures) == 0 && (!wantUsers || userResult.complete)
payload["hasMore"] = wantUsers && userResult.hasMore
if wantUsers && userResult.nextCursor != "" {
payload["nextCursor"] = userResult.nextCursor
}
return rt.Output(payload)
},
}
func validateGroupMembersPageLimit(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("page-limit"); limit < 1 || limit > groupMembersHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
return nil
}
type groupUserMembersResult struct {
members []map[string]any
pagesFetched int
complete bool
hasMore bool
nextCursor string
stopReason string
failures []map[string]any
}
func collectGroupUserMembers(rt *shortcut.RuntimeContext, groupID string, pageLimit int) (groupUserMembersResult, error) {
pageLimit = defaultChatPageLimit(pageLimit, groupMembersDefaultPageLimit)
result := groupUserMembersResult{
members: []map[string]any{},
failures: []map[string]any{},
stopReason: "source_complete",
}
cursor := "0"
seenCursors := map[string]bool{cursor: true}
seenIDs := map[string]bool{}
for result.pagesFetched < pageLimit {
data, err := rt.CallMCPData("chat", "get_group_members", map[string]any{
"openconversation_id": groupID,
"cursor": cursor,
})
if err != nil {
if result.pagesFetched > 0 {
result.failures = append(result.failures, map[string]any{
"bucket": "users",
"page": result.pagesFetched + 1,
"stage": "read",
"error": err.Error(),
})
result.stopReason = "read_failure"
return result, nil
}
return result, err
}
result.pagesFetched++
for _, member := range groupMemberProject(data) {
stableID := strings.TrimSpace(fmt.Sprint(member["openDingtalkId"]))
if stableID != "" && stableID != "<nil>" && seenIDs[stableID] {
continue
}
if stableID != "" && stableID != "<nil>" {
seenIDs[stableID] = true
}
result.members = append(result.members, member)
}
page := chatmsg.Pagination(data)
hasMore, known := page["hasMore"].(bool)
if !known {
result.failures = append(result.failures, map[string]any{
"bucket": "users",
"page": result.pagesFetched,
"stage": "pagination",
"error": "群成员下层未返回可靠的 hasMore,无法证明用户桶完整",
})
result.stopReason = "pagination_error"
return result, nil
}
result.hasMore = hasMore
if !hasMore {
result.complete = true
result.stopReason = "source_complete"
return result, nil
}
nextCursor := strings.TrimSpace(fmt.Sprint(page["nextCursor"]))
if nextCursor == "" || nextCursor == "<nil>" || seenCursors[nextCursor] {
result.failures = append(result.failures, map[string]any{
"bucket": "users",
"page": result.pagesFetched,
"stage": "pagination",
"error": "群成员 hasMore=true 但 nextCursor 缺失或停滞",
})
result.stopReason = "pagination_error"
return result, nil
}
result.nextCursor = nextCursor
seenCursors[nextCursor] = true
cursor = nextCursor
}
result.stopReason = "page_limit"
return result, nil
}
func (result groupUserMembersResult) bucketPayload() map[string]any {
payload := map[string]any{
"count": len(result.members),
"pagesFetched": result.pagesFetched,
"complete": result.complete && len(result.failures) == 0,
"hasMore": result.hasMore,
"partial": len(result.failures) > 0 && len(result.members) > 0,
"stopReason": result.stopReason,
"failedCount": len(result.failures),
"failures": result.failures,
}
if result.nextCursor != "" {
payload["nextCursor"] = result.nextCursor
}
return payload
}
func (result groupUserMembersResult) payload(groupID string) map[string]any {
payload := result.bucketPayload()
payload["contractVersion"] = groupMembersContractVersion
payload["conversationId"] = groupID
payload["members"] = result.members
return payload
}
func resolveMemberTypes(raw []string) (bool, bool, error) {
if len(raw) == 0 {
return true, true, nil
}
var users, bots bool
for _, value := range raw {
switch strings.ToLower(strings.TrimSpace(value)) {
case "user", "users":
users = true
case "bot", "bots":
bots = true
case "":
continue
default:
return false, false, apperrors.NewValidation(
fmt.Sprintf("--member-types 只支持 user,bot,收到 %q", value))
}
}
if !users && !bots {
return false, false, apperrors.NewValidation("--member-types 至少包含 user 或 bot")
}
return users, bots, nil
}
func groupBotProject(data map[string]any) []map[string]any {
var items []any
for _, root := range []map[string]any{data, groupMemberChildMap(data, "result")} {
if root == nil {
continue
}
for _, key := range []string{"bots", "robots", "list", "items"} {
if value, ok := root[key].([]any); ok {
items = value
break
}
}
if items != nil {
break
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
bot, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{
"name": groupMemberFirst(bot, "name", "robotName", "botName"),
"openDingtalkId": groupMemberFirst(bot, "botOpenDingTalkId", "openDingTalkId"),
"openBotId": groupMemberFirst(bot, "openBotId", "botId"),
"robotCode": groupMemberFirst(bot, "robotCode", "code"),
}
out = append(out, row)
}
return out
}
// groupMemberProject reshapes a get_group_members response into a clean
// {name, nick, role, openDingtalkId} list. The real payload wraps members in
// result.list[]; probe a few container shapes defensively.
func groupMemberProject(data map[string]any) []map[string]any {
var items []any
for _, root := range []map[string]any{data, groupMemberChildMap(data, "result")} {
if root == nil {
continue
}
for _, key := range []string{"list", "members", "memberList", "items", "records", "data"} {
if arr, ok := root[key].([]any); ok {
items = arr
break
}
}
if items != nil {
break
}
}
out := make([]map[string]any, 0, len(items))
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
row := map[string]any{
"name": groupMemberFirst(m, "memberEmpName", "empName", "name", "userName", "staffName"),
"nick": groupMemberFirst(m, "memberNick", "nick", "groupNick", "memberGroupNick"),
"role": groupMemberFirst(m, "memberRoleDesc", "roleDesc", "role"),
"openDingtalkId": groupMemberFirst(m, "openDingtalkId", "openDingTalkId", "memberDingtalkId"),
}
out = append(out, row)
}
return out
}
func groupMemberChildMap(data map[string]any, key string) map[string]any {
if m, ok := data[key].(map[string]any); ok {
return m
}
return nil
}
// groupMemberFirst returns the first non-empty string among the candidate keys.
func groupMemberFirst(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
func init() {
shortcut.Register(GroupMembers, ChatMembersList)
}