// 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 ( "encoding/json" "fmt" "strconv" "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" ) // DeptMembers: list the members of a department, by NAME, in one command. // // Steps: search the directory for the department by keyword // (search_dept_by_keyword) → defensively parse the unique deptId out of the // response → print that department's direct members // (get_dept_members_by_deptId). Replaces the manual dance of // `contact dept search --query <名称>` → copy deptId → // `contact dept list-members --depts `. // // Disambiguation: if the name matches zero or several departments, it does NOT // guess — it errors and lists the candidates (name + deptId) so the caller can // re-run against a specific department. // // Scope note: get_dept_members_by_deptId returns only the direct members of the // resolved department, it does NOT recurse into sub-departments. // // dws contact +dept-members --dept 技术部 var DeptMembers = shortcut.Shortcut{ Service: "contact", Command: "+dept-members", Product: "contact", Description: "按部门名列出部门成员(自动解析 deptId)", Intent: "当你只知道某个部门的名称、想知道该部门里都有哪些成员时使用;" + "内部先按部门名搜索出唯一的 deptId(匹配到 0 个或多个时不猜测," + "而是列出候选部门名与 deptId 让你选),再打印该部门的直接成员列表" + "(仅本部门,不递归下级部门)。只读,不做任何修改。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: corecmd.ContractDecl{ Identity: contract.ToolIdentitySpec{ ProductID: "contact", Name: "shortcut_dept_members", CanonicalPath: "contact.shortcut_dept_members", CLIPath: "contact +dept-members", PrimaryCLIPath: "contact +dept-members", }, Description: "按部门名列出部门成员(自动解析 deptId)", 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: "按部门名列出部门成员(自动解析 deptId)", UseWhen: []string{"当你只知道某个部门的名称、想知道该部门里都有哪些成员时使用;内部先按部门名搜索出唯一的 deptId(匹配到 0 个或多个时不猜测,而是列出候选部门名与 deptId 让你选),再打印该部门的直接成员列表(仅本部门,不递归下级部门)。只读,不做任何修改。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +dept-members --dept 技术部"}, }, }, Flags: []shortcut.Flag{ {Name: "dept", Type: shortcut.FlagString, Desc: "部门名/关键词", Required: true}, }, Tips: []string{`dws contact +dept-members --dept 技术部`}, Execute: func(rt *shortcut.RuntimeContext) error { if err := rt.RequireAll("dept"); err != nil { return err } keyword := strings.TrimSpace(rt.Str("dept")) // Step 1 — search the directory for the department by keyword. data, err := rt.CallMCPData("contact", "search_dept_by_keyword", map[string]any{ "query": keyword, }) if err != nil { return err } // Step 2 — defensively pull the matching departments out of the // response and require exactly one, otherwise disambiguate. depts := deptMembersExtractDepts(data) switch { case len(depts) == 0: return apperrors.NewValidation(fmt.Sprintf( "没找到名字里包含 %q 的部门;换个更准确的部门名再试。", keyword)) case len(depts) > 1: return apperrors.NewValidation(fmt.Sprintf( "%q 匹配到 %d 个部门:%s。请用更精确的部门名,"+ "或直接用 dws contact dept list-members --depts 。", keyword, len(depts), strings.Join(deptMembersLabels(depts), "、"))) } // Step 3 — list that department's direct members. // get_dept_members_by_deptId expects deptIds as a string list. return rt.CallMCP("get_dept_members_by_deptId", map[string]any{ "deptIds": []string{strconv.FormatInt(depts[0].id, 10)}, }) }, } // deptMembersDept is the minimal identity parsed out of a department search hit. type deptMembersDept struct { id int64 name string } // deptMembersExtractDepts walks the search_dept_by_keyword response and returns // the departments it can find. The real-machine payload may wrap the list under // a few different keys ({"result": [...]}, {"data": [...]}, or a bare list), so // probe several candidate shapes before giving up. Each hit needs a numeric // deptId; the deptName is best-effort for the disambiguation message. func deptMembersExtractDepts(data map[string]any) []deptMembersDept { var out []deptMembersDept seen := map[int64]bool{} for _, item := range deptMembersCandidateItems(data) { id, ok := deptMembersToInt64(deptMembersFirst(item, "deptId", "dept_id", "id")) if !ok || seen[id] { continue } seen[id] = true name, _ := deptMembersFirst(item, "deptName", "dept_name", "name").(string) // search_dept_by_keyword wraps the matched keyword in … // highlight markup; strip it so the disambiguation message is clean. out = append(out, deptMembersDept{id: id, name: stripHighlightTags(name)}) } return out } // deptMembersCandidateItems flattens the several shapes the dept-search payload // may take into a flat list of department-object maps to probe. func deptMembersCandidateItems(data map[string]any) []map[string]any { var out []map[string]any if data == nil { return out } appendList := func(v any) { if list, ok := v.([]any); ok { for _, item := range list { if m, ok := item.(map[string]any); ok { out = append(out, m) } } } } for _, key := range []string{"result", "data", "deptList", "departments", "list", "items"} { appendList(data[key]) } // The object itself may already be a single department record. if _, ok := deptMembersToInt64(deptMembersFirst(data, "deptId", "dept_id", "id")); ok { out = append(out, data) } return out } // deptMembersFirst returns the first non-nil value among the given keys. func deptMembersFirst(m map[string]any, keys ...string) any { for _, k := range keys { if v, ok := m[k]; ok && v != nil { return v } } return nil } // deptMembersToInt64 coerces a JSON-decoded numeric/string deptId to int64. func deptMembersToInt64(v any) (int64, bool) { switch n := v.(type) { case float64: return int64(n), true case int64: return n, true case int: return int64(n), true case json.Number: if i, err := n.Int64(); err == nil { return i, true } case string: if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil { return i, true } } return 0, false } // deptMembersLabels renders "()" candidates for the // disambiguation message. func deptMembersLabels(depts []deptMembersDept) []string { out := make([]string, 0, len(depts)) for _, d := range depts { name := d.name if name == "" { name = "(未命名)" } out = append(out, fmt.Sprintf("%s(%d)", name, d.id)) } return out } func init() { shortcut.Register(DeptMembers) }