diff --git a/internal/shortcut/smart/action_items.go b/internal/shortcut/smart/action_items.go new file mode 100644 index 00000000..19587027 --- /dev/null +++ b/internal/shortcut/smart/action_items.go @@ -0,0 +1,75 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ActionItems: fetch the extracted to-do items (待办事项) from MY most recent +// minutes (听记) in one step. +// +// Steps: +// 1. list my minutes via list_by_keyword_and_time_range +// (belongingConditionId = "created", maxResults = 20); +// 2. pick the newest entry (largest create time, falling back to the first +// item since lists come back newest-first) and read its taskUuid; +// 3. print that minute's extracted to-do items via list_minutes_todos. +// +// If the list is empty it reports "暂无妙记" instead of failing obscurely. +// +// dws minutes +action-items +var ActionItems = shortcut.Shortcut{ + Service: "minutes", + Command: "+action-items", + Product: "minutes", + Description: "取我最新一条妙记里的待办事项", + Intent: "当你刚开完会、只想立刻知道自己最近这条听记里系统帮你提取出了哪些待办事项," + + "却不想先翻听记列表、复制 taskUuid、再手动去查待办时使用;" + + "内部会先列出你创建的听记,自动挑出最新的一条,再拉取这条听记里提取的待办事项清单(含待办内容、参与人、待办时间等)。" + + "这是只读操作,不会新增或修改任何待办;若你名下没有任何听记则提示「暂无妙记」。", + Risk: shortcut.RiskRead, + Tips: []string{ + `dws minutes +action-items`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my minutes (newest first). belongingConditionId / + // maxResults mirror helpers.callListByKeywordRange. + data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{ + "belongingConditionId": "created", + "maxResults": float64(20), + }) + if err != nil { + return err + } + + // Step 2 — locate the newest minute's taskUuid. Reuse the defensive + // list/UUID/createTime parsing from latest_minutes.go. + taskUUID := latestMinutesTaskUUID(data) + if taskUUID == "" { + return apperrors.NewValidation("暂无妙记") + } + + // Step 3 — print its extracted to-do items (taskUuid param mirrors + // helpers' list_minutes_todos call). + return rt.CallMCP("list_minutes_todos", map[string]any{ + "taskUuid": taskUUID, + }) + }, +} + +func init() { + shortcut.Register(ActionItems) +} diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go new file mode 100644 index 00000000..3d863c1b --- /dev/null +++ b/internal/shortcut/smart/approve.go @@ -0,0 +1,280 @@ +// 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" + "strconv" + "strings" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its +// title / order number, in a single command (钉钉原生编排能力). +// +// Steps (all tool names + params copied verbatim from helpers/oa.go): +// +// 1. list my pending approvals via list_pending_approvals (starTime / endTime +// as float64 millis over a recent window, plus query=keyword for +// server-side narrowing — mirroring `dws oa approval list-pending`); +// +// 2. scan the returned instances and keep the ones whose title / order number +// contains --keyword: none → "没找到待审批单据", many → list candidates +// (标题 + processInstanceId) so the caller can be more specific, exactly one +// → take its processInstanceId; +// +// 3. resolve the pending taskId for that instance via list_pending_tasks +// (processInstanceId), then agree via approve_processInstance +// (processInstanceId + taskId as float64, optional remark) — +// mirroring `dws oa approval tasks` + `dws oa approval approve`. +// +// dws oa +approve-by --keyword 报销 +// dws oa +approve-by --keyword 出差单 --comment "同意" +var Approve = shortcut.Shortcut{ + Service: "oa", + Command: "+approve-by", + Product: "oa", + Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" + + "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" + + "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," + + "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, + {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false}, + }, + Tips: []string{ + `dws oa +approve-by --keyword 报销`, + `dws oa +approve-by --keyword 出差单 --comment "同意"`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("keyword") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词") + } + + // Step 1 — list my pending approvals. starTime/endTime are float64 + // milliseconds and query narrows server-side, mirroring + // helpers.list-pending (list_pending_approvals). Window: last ~90 days. + now := time.Now() + listArgs := map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), + "endTime": float64(now.UnixMilli()), + "query": keyword, + } + data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs) + if err != nil { + return err + } + + // Step 2 — match by title / order number substring. + matches := shortcutApproveMatch(data, keyword) + switch { + case len(matches) == 0: + return apperrors.NewValidation(fmt.Sprintf( + "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword)) + case len(matches) > 1: + return apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s", + keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";"))) + } + + instanceID := matches[0].instanceID + + // Step 3a — resolve the pending taskId for this instance via + // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`. + tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{ + "processInstanceId": instanceID, + }) + if err != nil { + return err + } + taskID := shortcutApproveTaskID(tasksData) + if taskID == "" { + return apperrors.NewValidation(fmt.Sprintf( + "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。", + matches[0].title, instanceID)) + } + + // Step 3b — agree. taskId is passed as float64 and remark is optional, + // mirroring helpers.approve (approve_processInstance). + taskIDNum, err := strconv.ParseFloat(taskID, 64) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID)) + } + approveArgs := map[string]any{ + "processInstanceId": instanceID, + "taskId": taskIDNum, + } + if c := strings.TrimSpace(rt.Str("comment")); c != "" { + approveArgs["remark"] = c + } + return rt.CallMCP("approve_processInstance", approveArgs) + }, +} + +// shortcutApproveInstance is the minimal identity we need from a pending-approval +// entry: its processInstanceId (fed to approve/tasks) and a human title/order +// number for matching + disambiguation. +type shortcutApproveInstance struct { + instanceID string + title string +} + +// shortcutApproveMatch walks a list_pending_approvals response and returns the +// instances whose title / order number contains keyword (case-insensitive). The +// gateway wraps the list under one of several common container keys, so we probe +// them defensively (mirroring latestMinutesItems / shortcutTodoCards). +func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance { + items := shortcutApproveItems(data) + kw := strings.ToLower(keyword) + var out []shortcutApproveInstance + seen := map[string]bool{} + for _, m := range items { + id := shortcutApproveInstanceID(m) + if id == "" || seen[id] { + continue + } + title := shortcutApproveTitle(m) + // Match on either the visible title/order number or the instance id. + if !strings.Contains(strings.ToLower(title), kw) && + !strings.Contains(strings.ToLower(id), kw) { + continue + } + seen[id] = true + if title == "" { + title = id + } + out = append(out, shortcutApproveInstance{instanceID: id, title: title}) + } + return out +} + +// shortcutApproveItems pulls the list of pending-approval entries out of the +// response, probing common container shapes before scanning for a bare list. +func shortcutApproveItems(data map[string]any) []map[string]any { + for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} { + if arr, ok := data[key].([]any); ok { + return shortcutApproveToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} { + if arr, ok := inner[k2].([]any); ok { + return shortcutApproveToMaps(arr) + } + } + } + } + return nil +} + +func shortcutApproveToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutApproveInstanceID reads an entry's processInstanceId (the value the +// helper feeds to --instance-id), tolerating a few common field names and both +// string / numeric JSON encodings. +func shortcutApproveInstanceID(m map[string]any) string { + for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} { + if s := shortcutApproveStr(m[key]); s != "" { + return s + } + } + return "" +} + +// shortcutApproveTitle reads a human-facing title / order number for matching +// and disambiguation, probing common field names. +func shortcutApproveTitle(m map[string]any) string { + for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} { + if s := shortcutApproveStr(m[key]); s != "" { + return s + } + } + return "" +} + +// shortcutApproveTaskID walks a list_pending_tasks response and returns the +// first pending taskId (the value the helper feeds to --task-id). +func shortcutApproveTaskID(data map[string]any) string { + items := shortcutApproveItems(data) + // list_pending_tasks may also return a bare {"result": [...]}/task list; the + // probe above covers those. Some gateways return a single task object. + if len(items) == 0 { + if id := shortcutApproveTaskIDField(data); id != "" { + return id + } + if r, ok := data["result"].(map[string]any); ok { + if id := shortcutApproveTaskIDField(r); id != "" { + return id + } + } + return "" + } + for _, m := range items { + if id := shortcutApproveTaskIDField(m); id != "" { + return id + } + } + return "" +} + +func shortcutApproveTaskIDField(m map[string]any) string { + for _, key := range []string{"taskId", "taskID", "activityId"} { + if s := shortcutApproveStr(m[key]); s != "" { + return s + } + } + return "" +} + +// shortcutApproveStr coerces a JSON value into a string, tolerating string and +// numeric encodings (taskId / instance id can arrive as either). +func shortcutApproveStr(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case int64: + return strconv.FormatInt(t, 10) + } + return "" +} + +func shortcutApproveLabels(items []shortcutApproveInstance) []string { + out := make([]string, 0, len(items)) + for _, c := range items { + out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID)) + } + return out +} + +func init() { + shortcut.Register(Approve) +} diff --git a/internal/shortcut/smart/assign.go b/internal/shortcut/smart/assign.go new file mode 100644 index 00000000..5c403a92 --- /dev/null +++ b/internal/shortcut/smart/assign.go @@ -0,0 +1,65 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Assign: create a todo AND assign it to a person by NAME, in one command. +// +// Steps: resolve the assignee name → userId → create a personal todo with that +// user as executor. Replaces `contact +search-user` (copy userId) → +// `todo +create --executors `. +// +// dws todo +assign --to 张三 --task "整理周报" +var Assign = shortcut.Shortcut{ + Service: "todo", + Command: "+assign", + Product: "todo", + Description: "按姓名给某人创建并指派一条待办(自动解析 userId)", + Intent: "当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;" + + "内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "to", Type: shortcut.FlagString, Desc: "执行人姓名/花名", Required: true}, + {Name: "task", Type: shortcut.FlagString, Desc: "待办标题/内容", Required: true}, + {Name: "due", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选)"}, + }, + Tips: []string{`dws todo +assign --to 张三 --task "整理周报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the assignee name to a unique userId. + user, err := resolveUser(rt, rt.Str("to")) + if err != nil { + return err + } + + // Step 2 — create the todo with that user as executor. create_personal_todo + // accepts executorIds directly, so assignment is part of creation. + vo := map[string]any{ + "subject": rt.Str("task"), + "executorIds": []string{user.userID}, + } + if rt.Changed("due") { + vo["dueTime"] = rt.Str("due") + } + return rt.CallMCP("create_personal_todo", map[string]any{ + "PersonalTodoCreateVO": vo, + }) + }, +} + +func init() { + shortcut.Register(Assign) +} diff --git a/internal/shortcut/smart/assign_multi.go b/internal/shortcut/smart/assign_multi.go new file mode 100644 index 00000000..0909c1ee --- /dev/null +++ b/internal/shortcut/smart/assign_multi.go @@ -0,0 +1,120 @@ +// 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" +) + +// AssignMulti: create ONE personal todo and assign it to SEVERAL people at once, +// addressing every executor by NAME instead of by userId. +// +// Steps: split the --to name CSV, resolve each name to a unique userId +// (resolveUser, which never guesses — it errors on unknown/ambiguous names), +// collect ALL resolution errors first and abort if any name failed, so we never +// create a half-assigned todo. Then create the todo once via create_personal_todo +// with every resolved userId in executorIds. Replaces the manual dance of +// `contact user search` per name → copy each userId → +// `todo task create --executors id1,id2,... --title ...`. +// +// tool name + params (create_personal_todo, PersonalTodoCreateVO.subject / +// .executorIds) are copied verbatim from the todo helper's `task create` call +// site. +// +// dws todo +assign-multi --to "张三,李四,王五" --task "周五前提交排期" +var AssignMulti = shortcut.Shortcut{ + Service: "todo", + Command: "+assign-multi", + Product: "todo", + Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)", + Intent: "当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;" + + "内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义," + + "就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。" + + "全部姓名都解析成功后,才用这些 userId 一次性创建这条待办并指派给所有人。会真实创建一条新的待办。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "执行人姓名/花名,逗号分隔(如 张三,李四)", Required: true}, + {Name: "task", Type: shortcut.FlagString, Desc: "待办标题", Required: true}, + }, + Tips: []string{ + `dws todo +assign-multi --to "张三,李四" --task "周五前提交排期"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + task := strings.TrimSpace(rt.Str("task")) + if task == "" { + return apperrors.NewValidation("--task 不能为空(待办标题)") + } + + // Normalize the --to name list, dropping blanks. + var names []string + for _, n := range rt.StrSlice("to") { + if n = strings.TrimSpace(n); n != "" { + names = append(names, n) + } + } + if len(names) == 0 { + return apperrors.NewValidation("--to 不能为空,请至少提供一个执行人姓名") + } + + // Resolve every name up front. Collect all failures and abort before any + // write, so we never create a todo assigned to only some of the people. + var ( + executorIDs []string + resolved []string + failures []string + ) + for _, name := range names { + user, err := resolveUser(rt, name) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %s", name, err.Error())) + continue + } + executorIDs = append(executorIDs, user.userID) + resolved = append(resolved, fmt.Sprintf("%s(%s)", user.name, user.userID)) + } + if len(failures) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "以下执行人姓名无法唯一解析,已中止、未创建任何待办:\n%s", + strings.Join(failures, "\n"))) + } + if len(executorIDs) == 0 { + return apperrors.NewValidation("没有解析出任何有效的执行人 userId,已中止") + } + + // Create the todo once, assigning all resolved executors. Params mirror the + // todo helper's `task create` call site verbatim. + if err := rt.CallMCP("create_personal_todo", map[string]any{ + "PersonalTodoCreateVO": map[string]any{ + "subject": task, + "executorIds": executorIDs, + }, + }); err != nil { + return err + } + + return rt.Output(map[string]any{ + "subject": task, + "executors": resolved, + "count": len(executorIDs), + }) + }, +} + +func init() { + shortcut.Register(AssignMulti) +} diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go new file mode 100644 index 00000000..904a3c59 --- /dev/null +++ b/internal/shortcut/smart/at_me.go @@ -0,0 +1,291 @@ +// 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 ( + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// AtMe: pull the messages that recently @-mentioned ME across chats in one step. +// +// Steps: +// 1. compute the look-back window [now-Nd, now] in local time and express both +// bounds as epoch millis. N defaults to 7 days and is overridable via +// --days; mirroring `dws chat message list-mentions`, which feeds startTime/ +// endTime as epoch millis to search_at_me_message. +// 2. call search_at_me_message on the chat server with startTime/endTime/limit/ +// cursor — the exact parameter names + first-page defaults (limit 50, +// cursor "0") used by helpers.chatMessageListMentionsCmd. +// 3. defensively project each returned message down to {sender, time, text, +// conversation} (multiple candidate keys per field) and print via rt.Output +// so it honours --format/--jq/--fields. When the response carries no +// recognisable message list we fall back to printing the raw payload. +// +// This replaces manually working out the millisecond time window and copying the +// list-mentions incantation. Read-only: it only searches and reshapes, never +// sends, recalls or marks anything. +// +// dws chat +at-me +// dws chat +at-me --days 3 +var AtMe = shortcut.Shortcut{ + Service: "chat", + Command: "+at-me", + Product: "chat", + Description: "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)", + Intent: "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;" + + "内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息," + + "再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" + + "这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false}, + }, + Tips: []string{ + `dws chat +at-me`, + `dws chat +at-me --days 3`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults + // to 7; guard against non-positive overrides so the window stays sane. + days := rt.Int("days") + if days <= 0 { + days = 7 + } + now := time.Now() + startMs := now.AddDate(0, 0, -days).UnixMilli() + endMs := now.UnixMilli() + + // Step 2 — search @me messages. startTime/endTime/limit/cursor and the + // first-page defaults (limit 50, cursor "0") mirror + // helpers.chatMessageListMentionsCmd's search_at_me_message call. + data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{ + "startTime": startMs, + "endTime": endMs, + "limit": 50, + "cursor": "0", + }) + if err != nil { + return err + } + + // Step 3 — project matched messages; fall back to the raw payload when we + // cannot locate a recognisable message list. + items := atMeMessageItems(data) + if len(items) == 0 { + return rt.Output(data) + } + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "sender": atMeSender(m), + "time": atMeTime(m), + "text": atMeText(m), + "conversation": atMeConversation(m), + }) + } + return rt.Output(map[string]any{"messages": results}) + }, +} + +// atMeMessageItems locates the message list inside a search_at_me_message +// response, probing common container keys at the top level and nested under +// "result". Returns nil when no list is found. +func atMeMessageItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + // Preferred real shape (verified against the live gateway): + // {result:{conversationMessagesList:[{title,openConversationId,messages:[…]}]}} + // The messages are nested two levels deep and grouped per conversation, so + // flatten every group into a single message list while carrying the group's + // conversation title/id down onto each message. + for _, root := range []map[string]any{data, atMeChildMap(data, "result")} { + if root == nil { + continue + } + if groups, ok := root["conversationMessagesList"].([]any); ok { + return atMeFlattenGroups(groups) + } + } + keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return atMeToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} { + if arr, ok := inner[k2].([]any); ok { + return atMeToMaps(arr) + } + } + } + } + return nil +} + +// atMeChildMap returns data[key] as a map, or nil. +func atMeChildMap(data map[string]any, key string) map[string]any { + if m, ok := data[key].(map[string]any); ok { + return m + } + return nil +} + +// atMeFlattenGroups flattens conversationMessagesList groups into a single +// message list, injecting the group's conversation title / id onto each message +// (when the message itself lacks them) so the projection can show a readable +// conversation. +func atMeFlattenGroups(groups []any) []map[string]any { + var out []map[string]any + for _, g := range groups { + grp, ok := g.(map[string]any) + if !ok { + continue + } + title := atMeString(grp["title"]) + cid := atMeString(grp["openConversationId"]) + msgs, ok := grp["messages"].([]any) + if !ok { + continue + } + for _, mm := range msgs { + m, ok := mm.(map[string]any) + if !ok { + continue + } + if title != "" { + if _, has := m["conversationTitle"]; !has { + m["conversationTitle"] = title + } + } + if cid != "" { + if _, has := m["openConversationId"]; !has { + m["openConversationId"] = cid + } + } + out = append(out, m) + } + } + return out +} + +func atMeToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// atMeSender reads a message's sender display name/id, tolerating the common +// sender keys the gateway may use (including a nested sender object). +func atMeSender(m map[string]any) any { + for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} { + if v := atMeString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"sender", "from", "senderUser"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} { + if v := atMeString(nested[k2]); v != "" { + return v + } + } + } + if v := atMeString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { + if v := atMeString(m[key]); v != "" { + return v + } + } + return nil +} + +// atMeTime reads a message's send time, returning the raw value (usually epoch +// millis) under whichever candidate key is present. +func atMeTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "time", "msgTime", "createAt"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +// atMeText reads a message's textual content, tolerating flat text keys and a +// nested content/text object. +func atMeText(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body"} { + if v := atMeString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"content", "text", "msg"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"text", "content", "richText", "title"} { + if v := atMeString(nested[k2]); v != "" { + return v + } + } + } + } + return nil +} + +// atMeConversation reads the conversation (chat) this message belongs to, +// preferring a readable title and falling back to the conversation id. +func atMeConversation(m map[string]any) any { + for _, key := range []string{"conversationTitle", "chatTitle", "groupName", "conversationName", "title"} { + if v := atMeString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"openConversationId", "conversationId", "conversation_id", "cid", "chatId"} { + if v := atMeString(m[key]); v != "" { + return v + } + } + return nil +} + +// atMeString coerces a scalar JSON value to a trimmed string, returning "" for +// nil / non-scalar / empty values. +func atMeString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func init() { + shortcut.Register(AtMe) +} diff --git a/internal/shortcut/smart/book.go b/internal/shortcut/smart/book.go new file mode 100644 index 00000000..60221089 --- /dev/null +++ b/internal/shortcut/smart/book.go @@ -0,0 +1,155 @@ +// 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" +) + +// Book: create a calendar event AND (optionally) invite participants BY NAME, +// in one command, with automatic rollback if inviting fails. +// +// Steps: create the event (summary + start/end) → if --with is given, resolve +// each name to a unique userId and add them all as participants; if that add +// fails, delete the just-created event so we never leave a half-built event +// behind. Replaces `calendar event create` (copy eventId) → +// `contact +search-user` (copy each userId) → `calendar attendee add`. +// +// dws calendar +book --title "Q1 复盘会" \ +// --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ +// --with 张三,李四 +var Book = shortcut.Shortcut{ + Service: "calendar", + Command: "+book", + Product: "calendar", + Description: "创建日程,并可按姓名邀请参会人(自动解析 userId,失败自动回滚删除日程)", + Intent: "当你想快速排一个会/日程、并顺手把几位同事按姓名拉进来时使用;" + + "内部先建日程拿到 eventId,再把每个姓名解析成唯一 userId 批量加为参会人。" + + "如果加参会人失败,会自动删除刚建好的日程回滚,避免留下一个没人的空日程。" + + "会真实创建日程并发出参会邀请。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "title", Type: shortcut.FlagString, Desc: "日程标题", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间(ISO8601,如 2026-03-10T14:00:00+08:00)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间(ISO8601,如 2026-03-10T15:00:00+08:00)", Required: true}, + {Name: "with", Type: shortcut.FlagString, Desc: "参会人姓名,逗号分隔(可选)"}, + }, + Tips: []string{ + `dws calendar +book --title "周会" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`, + `dws calendar +book --title "Q1 复盘会" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --with 张三,李四`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // create_calendar_event params copied verbatim from the helper's create + // call site: summary + startDateTime/endDateTime (ISO strings, not millis). + createArgs := map[string]any{ + "summary": rt.Str("title"), + "startDateTime": rt.Str("start"), + "endDateTime": rt.Str("end"), + } + + // Fast path: no participants → create and print in one step. + if !rt.Changed("with") || strings.TrimSpace(rt.Str("with")) == "" { + return rt.CallMCP("create_calendar_event", createArgs) + } + + // Step 1 — resolve every participant name to a unique userId BEFORE + // creating the event, so an unknown/ambiguous name fails cheaply without + // leaving a dangling event behind. + var userIDs []string + for _, name := range strings.Split(rt.Str("with"), ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + user, err := resolveUser(rt, name) + if err != nil { + return err + } + userIDs = append(userIDs, user.userID) + } + if len(userIDs) == 0 { + return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名") + } + + // Step 2 — create the event and pull the eventId from the response. + created, err := rt.CallMCPData("calendar", "create_calendar_event", createArgs) + if err != nil { + return err + } + eventID := shortcutExtractEventID(created) + if eventID == "" { + return apperrors.NewValidation("日程创建成功但无法从返回结果中解析 eventId,已跳过邀请参会人") + } + + // Step 3 — add all participants in one batch. attendeesToAdd copied + // verbatim from the helper's `attendee add` call site. + if _, err := rt.CallMCPData("calendar", "add_calendar_participant", map[string]any{ + "eventId": eventID, + "attendeesToAdd": userIDs, + }); err != nil { + // Rollback: delete the just-created event so we never leave a + // half-built event without its intended attendees. + _, delErr := rt.CallMCPData("calendar", "delete_calendar_event", map[string]any{ + "eventId": eventID, + }) + if delErr != nil { + return apperrors.NewValidation(fmt.Sprintf( + "添加参会人失败:%v;且回滚删除日程 %s 也失败:%v(请手动删除该日程)", + err, eventID, delErr)) + } + return apperrors.NewValidation(fmt.Sprintf( + "添加参会人失败:%v;已回滚删除日程 %s", err, eventID)) + } + + // Step 4 — success: print the final event detail (eventId + attendees). + return rt.CallMCP("get_calendar_detail", map[string]any{"eventId": eventID}) + }, +} + +// shortcutExtractEventID digs the eventId out of a create_calendar_event +// response, tolerating the common shapes ({eventId}, {id}, {result:{...}}, +// {event:{...}}) since the exact envelope is not guaranteed. +func shortcutExtractEventID(data map[string]any) string { + if data == nil { + return "" + } + if id := shortcutPickID(data); id != "" { + return id + } + for _, key := range []string{"result", "event", "data"} { + if nested, ok := data[key].(map[string]any); ok { + if id := shortcutPickID(nested); id != "" { + return id + } + } + } + return "" +} + +func shortcutPickID(m map[string]any) string { + for _, k := range []string{"eventId", "id"} { + if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func init() { + shortcut.Register(Book) +} diff --git a/internal/shortcut/smart/broadcast.go b/internal/shortcut/smart/broadcast.go new file mode 100644 index 00000000..b4cac327 --- /dev/null +++ b/internal/shortcut/smart/broadcast.go @@ -0,0 +1,106 @@ +// 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" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Broadcast: send the SAME single-chat message to several people by NAME. +// +// Steps: split the --to name CSV → resolve each name to a unique userId → +// send an individual single-chat message to each resolved user. Names that +// fail to resolve (unknown / ambiguous) are collected and reported at the end +// without aborting delivery to the others. Replaces manually running +// `chat +dm` once per recipient. +// +// dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意群公告" +var Broadcast = shortcut.Shortcut{ + Service: "chat", + Command: "+broadcast", + Product: "chat", + Description: "按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送)", + Intent: "当你想把同一条通知一次性单聊发给多位同事、但只知道他们的姓名不想逐个查 userId 时使用;" + + "内部把姓名列表逐个解析成唯一 userId 后,对每个人单独发一条单聊消息,并汇总成功/失败人数。" + + "某个姓名匹配不到人或匹配到多人时,会跳过该人并在结尾报出,不影响其他人收到消息。会真实发出多条消息。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件人姓名/花名,逗号分隔的多个人", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true}, + }, + Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + text := rt.Str("text") + names := rt.StrSlice("to") + if len(names) == 0 { + return apperrors.NewValidation("--to 至少要包含一个姓名") + } + + content, _ := json.Marshal(map[string]string{"title": text, "text": text}) + + var ( + sent []string + failed []string + ) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + + // Step 1 — resolve this name to a unique userId. On failure + // (unknown / ambiguous) record it and keep going. + user, err := resolveUser(rt, name) + if err != nil { + failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error())) + continue + } + + // Step 2 — send the single-chat message to this recipient. + if _, err := rt.CallMCPData("chat", "send_personal_message", map[string]any{ + "receiverUserId": user.userID, + "msgType": "markdown", + "content": string(content), + }); err != nil { + failed = append(failed, fmt.Sprintf("%s(发送失败:%s)", name, err.Error())) + continue + } + sent = append(sent, user.name) + } + + // Summarize via rt.Output (structured, honours --format/--jq/--fields) + // instead of fmt.Printf, which ignored the output flags. + if len(sent) == 0 { + return apperrors.NewValidation("没有任何人收到消息,请检查姓名是否正确") + } + result := map[string]any{ + "sentCount": len(sent), + "failedCount": len(failed), + "sent": sent, + } + if len(failed) > 0 { + result["failed"] = failed + } + return rt.Output(result) + }, +} + +func init() { + shortcut.Register(Broadcast) +} diff --git a/internal/shortcut/smart/by_mobile.go b/internal/shortcut/smart/by_mobile.go new file mode 100644 index 00000000..71aaed69 --- /dev/null +++ b/internal/shortcut/smart/by_mobile.go @@ -0,0 +1,130 @@ +// 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" + "strconv" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ByMobile: find a person by phone number and return their full profile in one +// step. +// +// Steps: look the mobile up via search_user_by_mobile → resolve to a userId +// (error clearly if nobody is bound to that number) → fetch full detail via +// get_user_info_by_user_ids. Replaces `contact user search-mobile` (copy the +// userId) → `contact user get --ids `. +// +// dws contact +by-mobile --mobile 13800138000 +var ByMobile = shortcut.Shortcut{ + Service: "contact", + Command: "+by-mobile", + Product: "contact", + Description: "按手机号查询某人的完整资料(自动解析 userId 后取详情)", + Intent: "当你只知道对方手机号、想一步拿到其完整资料(部门、职位、联系方式、是否管理员等)而不想先按手机号搜出 userId 再单独查详情时使用;" + + "内部先用手机号在通讯录里查出对应的 userId,若没有人绑定该手机号会明确报错,再用该 userId 取完整详情。这是纯只读操作,不会修改任何数据。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true}, + }, + Tips: []string{`dws contact +by-mobile --mobile 13800138000`}, + Execute: func(rt *shortcut.RuntimeContext) error { + if err := rt.RequireAll("mobile"); err != nil { + return err + } + mobile := rt.Str("mobile") + + // Step 1 — look up the userId bound to this mobile number. + // search_user_by_mobile takes {mobile} (see helpers.contact + // search-mobile). The gateway shape is not guaranteed, so probe common + // containers/field names defensively. + data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{ + "mobile": mobile, + }) + if err != nil { + return err + } + userID := byMobileExtractUserID(data) + if userID == "" { + return apperrors.NewValidation( + fmt.Sprintf("没找到绑定手机号 %q 的用户;确认号码正确、且该人在当前组织通讯录中。", mobile)) + } + + // Step 2 — fetch and print the full profile of the resolved user. + // get_user_info_by_user_ids takes {user_id_list} (see helpers.contact + // user get). + return rt.CallMCP("get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{userID}, + }) + }, +} + +// byMobileExtractUserID pulls a single userId out of a search_user_by_mobile +// response. The exact shape is not contractually fixed, so it probes a few +// likely containers (top-level, result, data, user) and field names +// (userId/userid/user_id), tolerating both string and numeric encodings. +func byMobileExtractUserID(data map[string]any) string { + if data == nil { + return "" + } + if id := byMobileUserIDFromMap(data); id != "" { + return id + } + for _, key := range []string{"result", "data", "user", "userInfo"} { + switch v := data[key].(type) { + case map[string]any: + if id := byMobileUserIDFromMap(v); id != "" { + return id + } + case []any: + for _, it := range v { + if m, ok := it.(map[string]any); ok { + if id := byMobileUserIDFromMap(m); id != "" { + return id + } + } + } + case string: + if v != "" { + return v + } + case float64: + return strconv.FormatInt(int64(v), 10) + } + } + return "" +} + +func byMobileUserIDFromMap(m map[string]any) string { + for _, key := range []string{"userId", "userid", "user_id", "userID", "id"} { + switch v := m[key].(type) { + case string: + if v != "" && v != "0" { + return v + } + case float64: + if v != 0 { + return strconv.FormatInt(int64(v), 10) + } + } + } + return "" +} + +func init() { + shortcut.Register(ByMobile) +} diff --git a/internal/shortcut/smart/calendar_shared.go b/internal/shortcut/smart/calendar_shared.go new file mode 100644 index 00000000..15be0f12 --- /dev/null +++ b/internal/shortcut/smart/calendar_shared.go @@ -0,0 +1,43 @@ +// 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 ( + "strings" + "time" +) + +// calendarDayRange returns the [00:00, next-day 00:00) window for "today plus +// offsetDays" in the local timezone. Shared by the day-scoped calendar +// shortcuts (+today offset 0, +tomorrow offset 1, +conflicts --in-days). +func calendarDayRange(offsetDays int) (time.Time, time.Time) { + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, offsetDays) + return start, start.AddDate(0, 0, 1) +} + +// calendarProjectEvents lists and projects the events in a list_calendar_events +// response into clean {title,start,end,location,eventId} items, skipping events +// that carry no id. Shared by +today / +tomorrow / +week so the projection lives +// in exactly one place (reuses shortcutNextEventProject). +func calendarProjectEvents(data map[string]any) []map[string]any { + events := make([]map[string]any, 0) + for _, e := range shortcutNextEventList(data) { + if id, _ := e["id"].(string); strings.TrimSpace(id) == "" { + continue + } + events = append(events, shortcutNextEventProject(e)) + } + return events +} diff --git a/internal/shortcut/smart/cancel_event.go b/internal/shortcut/smart/cancel_event.go new file mode 100644 index 00000000..f0ae5c7b --- /dev/null +++ b/internal/shortcut/smart/cancel_event.go @@ -0,0 +1,123 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// CancelEvent: cancel (delete) an EXISTING calendar event in one step, with a +// confirm-before-delete safety net so you never wipe the wrong eventId. +// +// Steps: first pull the event's detail via get_calendar_detail (so a bad or +// stale eventId fails clearly before any destructive write, and the title is +// surfaced back to the user). Then delete it via delete_calendar_event. +// Replaces `calendar event get --id` (verify it's the right one) → +// `calendar event delete --id` (destroy it). +// +// The eventId param is copied verbatim from the helper's `event get` +// (get_calendar_detail) and `event delete` (delete_calendar_event) call sites. +// This is a high-risk write; the framework asks for a second confirmation. +// +// dws calendar +cancel-event --event EVENT_ID +var CancelEvent = shortcut.Shortcut{ + Service: "calendar", + Command: "+cancel-event", + Product: "calendar", + Description: "取消(删除)一个已有日程(删除前先确认它真实存在)", + Intent: "当你想取消/删除一个已经存在的日程时使用;" + + "内部先用 eventId 拉一次日程详情确认它真实存在并回显标题,再执行删除," + + "避免因 eventId 写错而误删别的日程。" + + "如果 eventId 查不到会直接报错,不会盲目删除。" + + "这是高危写操作,会真实删除该日程,框架会二次确认。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "event", Type: shortcut.FlagString, Desc: "要取消的日程 eventId(可用 dws calendar event list 查询)", Required: true}, + }, + Tips: []string{`dws calendar +cancel-event --event EVENT_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + eventID := strings.TrimSpace(rt.Str("event")) + if eventID == "" { + return apperrors.NewValidation("--event 不能为空") + } + + // Step 1 — confirm the event exists before deleting. eventId param copied + // verbatim from the helper's `event get` call site (get_calendar_detail). + // If the eventId is bad, this errors out and we never reach the delete. + detail, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{ + "eventId": eventID, + }) + if err != nil { + return err + } + + // Defensively surface the event title so the user can see what is about to + // be cancelled. Structure is unknown, so probe several candidate fields at + // both the top level and under a nested detail/event/data wrapper; if none + // match we simply skip the projection and proceed to delete. + title := cancelEventExtractTitle(detail) + if title != "" { + _ = rt.Output(map[string]any{ + "eventId": eventID, + "title": title, + "action": "about_to_cancel", + }) + } + + // Step 2 — delete the event. eventId param copied verbatim from the + // helper's `event delete` call site (delete_calendar_event). + return rt.CallMCP("delete_calendar_event", map[string]any{ + "eventId": eventID, + }) + }, +} + +// cancelEventExtractTitle defensively digs a human-readable event title out of +// a get_calendar_detail response. It checks common title-ish keys at the top +// level and inside a single nested wrapper (detail/event/data/result), and +// returns "" when nothing usable is found. +func cancelEventExtractTitle(data map[string]any) string { + if data == nil { + return "" + } + if t := cancelEventTitleFromMap(data); t != "" { + return t + } + for _, k := range []string{"detail", "event", "data", "result"} { + if nested, ok := data[k].(map[string]any); ok { + if t := cancelEventTitleFromMap(nested); t != "" { + return t + } + } + } + return "" +} + +func cancelEventTitleFromMap(m map[string]any) string { + for _, k := range []string{"summary", "title", "subject", "name"} { + if v, ok := m[k].(string); ok { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + } + return "" +} + +func init() { + shortcut.Register(CancelEvent) +} diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go new file mode 100644 index 00000000..5c6aa9a7 --- /dev/null +++ b/internal/shortcut/smart/chat_messages.go @@ -0,0 +1,208 @@ +// 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 ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ChatMessages: fetch the message list of one conversation (group OR single +// chat) and print a clean projected list (speaker / text / time) instead of a +// raw dump. +// +// Steps: +// 1. depending on whether --group or --user is given, call either +// list_conversation_message_v2 (group; param openconversation_id) or +// list_individual_chat_message (single chat; param userId) on the chat +// server — tool names and param keys copied verbatim from chat.go's +// `dws chat message list` call sites; +// 2. defensively unwrap the message list (multiple candidate container keys) +// and project each message to {sender, text, createTime} tolerating field +// aliases and one level of nesting; +// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields. +// +// Read-only: it only reads a conversation's messages and reshapes them locally, +// never posts or mutates anything. +// +// dws chat +chat-messages --group --time "2025-03-01 00:00:00" +// dws chat +chat-messages --user --time "2025-03-01 00:00:00" --limit 50 +var ChatMessages = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-messages", + Product: "chat", + Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间", + Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" + + "群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" + + "可选 --time 指定起始时间、--limit 指定每页条数、--direction newer/older 控制时间方向(newer 从给定时间往现在拉,older 往以前拉)。" + + "内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" + + "这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"}, + {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, + {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"}, + {Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"}, + }, + Tips: []string{ + `dws chat +chat-messages --group --time "2025-03-01 00:00:00"`, + `dws chat +chat-messages --user --time "2025-03-01 00:00:00" --limit 50`, + `dws chat +chat-messages --group --direction older`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + // --group and --user are mutually exclusive and exactly one is required. + return rt.ExactlyOne("group", "user") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — build params and pick the right tool. Param keys + // (openconversation_id / userId / time / forward / limit) are copied + // verbatim from chat.go's `dws chat message list` call sites. + var tool string + params := map[string]any{} + + if rt.Changed("time") && rt.Str("time") != "" { + params["time"] = rt.Str("time") + } + if rt.Changed("limit") && rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + // direction newer/older maps to the tools' boolean `forward` param + // (newer -> forward=true, older -> forward=false), matching chat.go's + // resolveMessageForward. + if rt.Changed("direction") { + switch strings.TrimSpace(strings.ToLower(rt.Str("direction"))) { + case "newer": + params["forward"] = true + case "older": + params["forward"] = false + } + } + + if rt.Str("group") != "" { + tool = "list_conversation_message_v2" + params["openconversation_id"] = rt.Str("group") + } else { + tool = "list_individual_chat_message" + params["userId"] = rt.Str("user") + } + + data, err := rt.CallMCPData("chat", tool, params) + if err != nil { + return err + } + + // Step 2 — defensively unwrap and project. Response shape has no + // contract, so probe multiple candidate container/field keys. + items := chatMessageItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "sender": chatMessageSender(m), + "text": chatMessageText(m), + "createTime": chatMessageCreateTime(m), + }) + } + + return rt.Output(map[string]any{ + "messages": results, + "count": len(results), + }) + }, +} + +// chatMessageItems defensively unwraps the message list from the response, +// tolerating the common container keys and one level of nesting under a +// "result"/"data" wrapper. +func chatMessageItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + scopes := []map[string]any{data} + for _, wrap := range []string{"result", "data"} { + if inner, ok := data[wrap].(map[string]any); ok { + scopes = append(scopes, inner) + } + } + for _, scope := range scopes { + for _, key := range []string{"messages", "list", "items", "records", "data", "result"} { + if raw, ok := scope[key].([]any); ok { + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + if len(out) > 0 { + return out + } + } + } + } + return nil +} + +// chatMessageSender reads a message's speaker display name, tolerating common +// sender-name keys. +func chatMessageSender(m map[string]any) any { + for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { + if v, ok := m[key]; ok && v != nil { + if s, ok := v.(string); ok && s == "" { + continue + } + return v + } + } + return nil +} + +// chatMessageText reads a message's textual content, tolerating common text +// keys and one level of nesting (e.g. {"content":{"text":"..."}}). +func chatMessageText(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t != "" { + return t + } + case map[string]any: + for _, inner := range []string{"text", "content", "value"} { + if s, ok := t[inner].(string); ok && s != "" { + return s + } + } + } + } + return nil +} + +// chatMessageCreateTime reads a message's create/send time, returning the raw +// value under whichever candidate key is present. +func chatMessageCreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +func init() { + shortcut.Register(ChatMessages) +} diff --git a/internal/shortcut/smart/conflicts.go b/internal/shortcut/smart/conflicts.go new file mode 100644 index 00000000..52a3882e --- /dev/null +++ b/internal/shortcut/smart/conflicts.go @@ -0,0 +1,152 @@ +// 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 ( + "sort" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Conflicts: detect time-overlapping events on my calendar for a day — the +// scheduling sanity check ("do I have any double-bookings?") that neither the +// 1:1 layer offers. dws-native value. +// +// It lists list_calendar_events for the target day (default today, or N days +// ahead via --in-days), then locally finds every pair of events whose [start,end) +// intervals overlap and reports them. Read-only. +// +// dws calendar +conflicts +// dws calendar +conflicts --in-days 1 +var Conflicts = shortcut.Shortcut{ + Service: "calendar", + Command: "+conflicts", + Product: "calendar", + Description: "检测我某天日程的时间冲突(重叠/双重预订,默认今天)", + Intent: "当你想快速知道『我今天(或某天)的日程有没有时间冲突、撞车的会议』时使用;" + + "内部自动算出目标日期的时间范围(默认今天,可用 --in-days 指定几天后),列出当天全部日程," + + "再在本地两两比对开始/结束时间,找出所有时间段重叠的日程对并报告。" + + "只读操作,不修改任何日程;没有冲突时明确告诉你「无冲突」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "in-days", Type: shortcut.FlagInt, Desc: "几天后(可选,0=今天默认,1=明天…)", Required: false}, + }, + Tips: []string{ + `dws calendar +conflicts`, + `dws calendar +conflicts --in-days 1`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Target day window in local time. + dayStart, dayEnd := calendarDayRange(rt.Int("in-days")) + + data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{ + "startTime": dayStart.UnixMilli(), + "endTime": dayEnd.UnixMilli(), + "calendarId": "primary", + }) + if err != nil { + return err + } + + // Collect events that carry both a start and an end, keeping the parsed + // times for overlap comparison. + type ev struct { + title string + start time.Time + end time.Time + } + var evs []ev + for _, e := range shortcutNextEventList(data) { + start, ok := shortcutNextEventStart(e) + if !ok { + continue + } + end, ok := conflictsEndTime(e) + if !ok { + continue + } + title, _ := e["summary"].(string) + if strings.TrimSpace(title) == "" { + title = "(无标题)" + } + evs = append(evs, ev{title: title, start: start, end: end}) + } + sort.Slice(evs, func(i, j int) bool { return evs[i].start.Before(evs[j].start) }) + + // Find every overlapping pair. Two intervals [s1,e1),[s2,e2) overlap iff + // s1 < e2 && s2 < e1. + conflicts := make([]map[string]any, 0) + for i := 0; i < len(evs); i++ { + for j := i + 1; j < len(evs); j++ { + if evs[i].start.Before(evs[j].end) && evs[j].start.Before(evs[i].end) { + conflicts = append(conflicts, map[string]any{ + "a": conflictsLabel(evs[i].title, evs[i].start, evs[i].end), + "b": conflictsLabel(evs[j].title, evs[j].start, evs[j].end), + "overlapStart": conflictsMax(evs[i].start, evs[j].start).Format("15:04"), + "overlapEnd": conflictsMin(evs[i].end, evs[j].end).Format("15:04"), + }) + } + } + } + + return rt.Output(map[string]any{ + "date": dayStart.Format("2006-01-02"), + "eventCount": len(evs), + "conflictCount": len(conflicts), + "hasConflict": len(conflicts) > 0, + "conflicts": conflicts, + }) + }, +} + +// conflictsEndTime parses an event's end time from end.dateTime (RFC3339). +func conflictsEndTime(e map[string]any) (time.Time, bool) { + end, ok := e["end"].(map[string]any) + if !ok { + return time.Time{}, false + } + dt, ok := end["dateTime"].(string) + if !ok || dt == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, dt) + if err != nil { + return time.Time{}, false + } + return t, true +} + +func conflictsLabel(title string, start, end time.Time) string { + return title + "(" + start.Format("15:04") + "-" + end.Format("15:04") + ")" +} + +func conflictsMax(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func conflictsMin(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} + +func init() { + shortcut.Register(Conflicts) +} diff --git a/internal/shortcut/smart/created_todos.go b/internal/shortcut/smart/created_todos.go new file mode 100644 index 00000000..5fdce334 --- /dev/null +++ b/internal/shortcut/smart/created_todos.go @@ -0,0 +1,93 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// CreatedTodos: list the todos I created (rather than the ones assigned to me +// to execute) in one step. +// +// Steps: +// +// 1. list my todos via get_user_todos_in_current_org (pageNum / pageSize as +// strings, mirroring helpers.todo list) but with roleTypes=["creator"] so +// the server returns todos where I am the creator. "creator" is one of the +// three enum values accepted by helpers.parseRoleTypes +// (creator / executor / participant), so this is a supported role value. +// +// 2. project each todoCards[] entry to {subject, taskId, dueTime} with +// defensive field parsing (reusing shortcutTodoCards / shortcutTodoTaskID / +// shortcutOverdueDueTime), and print via rt.Output so it honours +// --format/--jq/--fields. +// +// Read-only: it only lists and projects, it never creates or mutates any todo. +// +// dws todo +created-todos +var CreatedTodos = shortcut.Shortcut{ + Service: "todo", + Command: "+created-todos", + Product: "todo", + Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)", + Intent: "当你想快速看清『哪些待办是我自己创建/发起的』,而不是别人指派给我执行的待办时使用;" + + "内部拉取你当前组织下角色为创建人(creator)的待办列表(roleTypes=[\"creator\"]," + + "creator 是待办列表支持的角色枚举之一),再在本地把每条待办投影成标题(subject)、" + + "任务 ID(taskId) 和截止时间(dueTime) 打印出来。这是纯只读操作,只做列表与投影," + + "不会创建或修改任何待办;若没有你创建的待办则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + // No required flags: "my created todos" is fully derived from the + // current identity plus roleTypes=["creator"]. + }, + Tips: []string{ + `dws todo +created-todos`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my todos as creator. pageNum/pageSize are strings and + // roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where + // "creator" is an accepted role-type enum value. + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{ + "pageNum": "1", + "pageSize": "50", + "roleTypes": []string{"creator"}, + }) + if err != nil { + return err + } + + // Step 2 — project each card to {subject, taskId, dueTime}, parsing + // fields defensively (helpers reused from todo_done.go / overdue.go). + cards := shortcutTodoCards(data) + created := make([]map[string]any, 0, len(cards)) + for _, m := range cards { + subject, _ := m["subject"].(string) + item := map[string]any{ + "subject": subject, + "taskId": shortcutTodoTaskID(m), + } + if due, ok := shortcutOverdueDueTime(m); ok { + item["dueTime"] = due + } + created = append(created, item) + } + + // Step 3 — print the projected list of todos I created. + return rt.Output(map[string]any{"created": created}) + }, +} + +func init() { + shortcut.Register(CreatedTodos) +} diff --git a/internal/shortcut/smart/dept_members.go b/internal/shortcut/smart/dept_members.go new file mode 100644 index 00000000..5ea0200d --- /dev/null +++ b/internal/shortcut/smart/dept_members.go @@ -0,0 +1,194 @@ +// 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" + + 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, + 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) +} diff --git a/internal/shortcut/smart/dm.go b/internal/shortcut/smart/dm.go new file mode 100644 index 00000000..297a4814 --- /dev/null +++ b/internal/shortcut/smart/dm.go @@ -0,0 +1,67 @@ +// 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 holds genuine multi-step / intelligent shortcuts — commands that +// orchestrate several MCP calls or resolve names to IDs, so they are NOT a 1:1 +// wrapper over a single tool. This is the "shortcut as a real capability" layer, +// distinct from the 1:1 ergonomic wrappers under the per-service packages. +package smart + +import ( + "encoding/json" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// DM: message a person by NAME, no ID juggling. +// +// Steps: resolve name → single userId (disambiguate on multiple matches) → send +// a single-chat message. Replaces `contact +search-user` (copy userId) → +// `chat +messages-send --user `. +// +// dws chat +dm --to 张三 --text "周报发我一下" +var DM = shortcut.Shortcut{ + Service: "chat", + Command: "+dm", + Product: "chat", + Description: "按姓名直接给某人发单聊消息(自动解析 userId)", + Intent: "当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;" + + "内部先按姓名搜通讯录解析出唯一 userId 再发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true}, + }, + Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + text := rt.Str("text") + + // Step 1 — resolve the recipient name to a unique userId. + user, err := resolveUser(rt, rt.Str("to")) + if err != nil { + return err + } + + // Step 2 — send the single-chat message. + content, _ := json.Marshal(map[string]string{"title": text, "text": text}) + return rt.CallMCP("send_personal_message", map[string]any{ + "receiverUserId": user.userID, + "msgType": "markdown", + "content": string(content), + }) + }, +} + +func init() { + shortcut.Register(DM) +} diff --git a/internal/shortcut/smart/doc_append.go b/internal/shortcut/smart/doc_append.go new file mode 100644 index 00000000..2580a9ec --- /dev/null +++ b/internal/shortcut/smart/doc_append.go @@ -0,0 +1,75 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// DocAppend: append a chunk of text to the END of a document in one command. +// +// Instead of asking you to first list the document blocks, figure out the +// child count / last-block index, then hand-build an insert_document_block +// element payload, this shortcut leans on update_document's built-in +// "append" mode — the helper documents mode=append as "追加 (在末尾追加,最安全)", +// i.e. it appends the given markdown to the document tail safely without +// touching existing content. Params (nodeId/markdown/mode) are copied verbatim +// from the update_document append call site in internal/helpers/doc.go. +// +// dws doc +doc-append --doc DOC_ID --text "今天的会议结论:下周一上线。" +var DocAppend = shortcut.Shortcut{ + Service: "doc", + Command: "+doc-append", + Product: "doc", + Description: "在文档末尾追加一段文本(安全追加,不改动原有内容)", + Intent: "当你只想往一篇钉钉文档的最后面补一段文字、又不想动原有内容时使用;" + + "内部用文档更新的“追加(append)”模式,把你给的文本安全地拼到文档末尾," + + "不需要你先去查文档块列表、算末尾位置或手工拼块结构。" + + "会真实写入文档内容。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "doc", Type: shortcut.FlagString, Desc: "文档 documentId / nodeId(或文档 URL/token)", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true}, + }, + Tips: []string{ + `dws doc +doc-append --doc DOC_ID --text "补充说明:本方案已评审通过。"`, + `dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/" --text "追加一行备注"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + nodeID := strings.TrimSpace(rt.Str("doc")) + if nodeID == "" { + return apperrors.NewValidation("--doc 不能为空,请提供文档 documentId/nodeId 或文档 URL") + } + text := rt.Str("text") + if strings.TrimSpace(text) == "" { + return apperrors.NewValidation("--text 不能为空,请提供要追加的文本") + } + + // update_document append: params copied verbatim from the helper's + // `doc update --mode append` call site — nodeId + markdown + mode. + // mode=append 追加到文档末尾(最安全,不清空原内容)。 + return rt.CallMCP("update_document", map[string]any{ + "nodeId": nodeID, + "markdown": text, + "mode": "append", + }) + }, +} + +func init() { + shortcut.Register(DocAppend) +} diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go new file mode 100644 index 00000000..f75b51fe --- /dev/null +++ b/internal/shortcut/smart/done_approvals.go @@ -0,0 +1,109 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// DoneApprovals: READ-ONLY list of the approval tasks I have already processed +// (my approval history), projected to clean fields. Unlike `oa +approve-by` +// (which agrees to an approval) or `oa +pending` (which lists what still waits +// for me), this shortcut only looks back at what I've already handled — it never +// approves / rejects / mutates anything. +// +// Steps (tool name + param keys copied verbatim from helpers/oa.go): +// +// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as +// float64, mirroring `dws oa approval list-executed`; optional --limit maps +// to pageSize (float64). This tool takes NO time window (unlike +// list_pending_approvals) — only pageNumber/pageSize/query. +// +// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems +// from approve.go, multiple candidate container keys + one nested level) and +// project each entry to a readable shape {title, originatorName, +// processInstanceId, createTime}, every field probed across candidate keys. +// +// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an +// empty raw dump. +// +// dws oa +done-approvals +// dws oa +done-approvals --limit 10 +var DoneApprovals = shortcut.Shortcut{ + Service: "oa", + Command: "+done-approvals", + Product: "oa", + Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" + + "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" + + "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" + + "若没有任何已处理记录则提示「没有已处理的审批记录」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, + }, + Tips: []string{ + `dws oa +done-approvals`, + `dws oa +done-approvals --limit 10`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys + // copied verbatim from helpers.list-executed (get_done_tasks). This tool + // carries no time window; pageNumber defaults to the first page. + // pageSize is required by get_done_tasks — the 1:1 list-executed always + // sends one (a missing/zero pageSize triggers a backend business error), + // so default to 20 and let --limit override. + params := map[string]any{ + "pageNumber": float64(1), + "pageSize": float64(20), + } + if rt.Changed("limit") { + if n := rt.Int("limit"); n > 0 { + params["pageSize"] = float64(n) + } + } + data, err := rt.CallMCPData("oa", "get_done_tasks", params) + if err != nil { + return err + } + + // Step 2 — project entries. shortcutApproveItems (approve.go) defensively + // unwraps the list container (result/list/instances/items/data/records/ + // processList, incl. one nested level). + items := shortcutApproveItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + // shortcutApproveTitle probes title/processTitle/name/subject/… + "title": shortcutApproveTitle(m), + // shortcutApproveInstanceID probes processInstanceId/instanceId/… + "processInstanceId": shortcutApproveInstanceID(m), + "originatorName": pendingApprovalsOriginator(m), + "createTime": pendingApprovalsCreateTime(m), + }) + } + + // Step 3 — empty result guard. + if len(results) == 0 { + return apperrors.NewValidation("没有已处理的审批记录") + } + + return rt.Output(map[string]any{"count": len(results), "done": results}) + }, +} + +func init() { + shortcut.Register(DoneApprovals) +} diff --git a/internal/shortcut/smart/due_today.go b/internal/shortcut/smart/due_today.go new file mode 100644 index 00000000..4211361e --- /dev/null +++ b/internal/shortcut/smart/due_today.go @@ -0,0 +1,132 @@ +// 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 ( + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// DueToday: list MY todos whose planFinishDate falls within today. +// +// Unlike +overdue (which locally keeps cards whose dueTime is already in the +// past), this shortcut filters SERVER-SIDE: it passes the today window as +// planFinishDateStart / planFinishDateEnd (epoch millis) to +// get_user_todos_in_current_org, so the backend returns only todos due today. +// +// Steps: +// +// 1. compute the [today 00:00, tomorrow 00:00) window in the local timezone, +// as epoch millis; +// +// 2. list my todos via get_user_todos_in_current_org with +// planFinishDateStart = today 00:00 ms, planFinishDateEnd = tomorrow 00:00 +// ms, roleTypes=["executor"] (mirroring +overdue), pageNum/pageSize as +// strings (mirroring helpers.todo list); +// +// 3. project each card to a clean {title, status, priority, creator, +// planFinishDate, taskId} shape and print via rt.Output so it honours +// --format/--jq/--fields. +// +// Read-only: it never mutates any todo, it only lists and projects. +// +// dws todo +due-today +var DueToday = shortcut.Shortcut{ + Service: "todo", + Command: "+due-today", + Product: "todo", + Description: "列出我今天到期的待办", + Intent: "当你想快速看清自己今天(planFinishDate 落在今天 00:00 到次日 00:00 之间)到期的待办、方便安排一天的工作时使用;" + + "内部按今天的本地时间窗,把 planFinishDateStart=今天0点、planFinishDateEnd=次日0点(毫秒时间戳)传给 get_user_todos_in_current_org 做服务端过滤," + + "默认拉取你作为执行人(executor)的待办,可用 --role-types 覆盖角色范围,最后只打印这些今天到期待办的标题、状态、优先级、创建人、到期时间和任务 ID。" + + "这与 +overdue(已过期)不同:+overdue 看的是已经过了截止时间的待办,本命令看的是今天当天到期的待办。" + + "这是纯只读操作,只做列表与投影,不会修改或完成任何待办;若今天没有到期的待办则返回错误提示。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + { + Name: "role-types", + Type: shortcut.FlagString, + Desc: "覆盖默认角色范围,逗号分隔,取值 creator/executor/participant;不传则默认 executor", + }, + }, + Tips: []string{ + `dws todo +due-today`, + `dws todo +due-today --role-types creator,executor`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — compute [today 00:00, tomorrow 00:00) in the local timezone. + now := time.Now() + loc := now.Location() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + startOfNextDay := startOfDay.AddDate(0, 0, 1) + startMs := startOfDay.UnixMilli() + endMs := startOfNextDay.UnixMilli() + + // Step 2 — build params. roleTypes defaults to executor (like +overdue); + // --role-types overrides it (validated like helpers.parseRoleTypes). The + // today window is passed as planFinishDateStart / planFinishDateEnd so the + // backend filters server-side. + roleTypes := []string{"executor"} + if rt.Changed("role-types") { + parsed, err := parseRelatedRoleTypes(rt.Str("role-types")) + if err != nil { + return err + } + if len(parsed) > 0 { + roleTypes = parsed + } + } + + params := map[string]any{ + "pageNum": "1", + "pageSize": "50", + "roleTypes": roleTypes, + "planFinishDateStart": startMs, + "planFinishDateEnd": endMs, + } + + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", params) + if err != nil { + return err + } + + // Step 3 — project the cards. The server already filtered to today's + // window; we keep a defensive local guard against any planFinishDate that + // leaks outside [startMs, endMs). + cards := shortcutTodoCards(data) // reused from todo_done.go + results := make([]map[string]any, 0, len(cards)) + for _, m := range cards { + if due, ok := shortcutOverdueDueTime(m); ok { // reused from overdue.go + if due < startMs || due >= endMs { + continue + } + } + taskID := shortcutRelatedTaskID(m) // reused from related_tasks.go + results = append(results, shortcutRelatedProject(m, taskID)) // reused from related_tasks.go + } + + if len(results) == 0 { + return apperrors.NewValidation("今天没有到期的待办") + } + + // Step 4 — print the projected list. + return rt.Output(map[string]any{"count": len(results), "tasks": results}) + }, +} + +func init() { + shortcut.Register(DueToday) +} diff --git a/internal/shortcut/smart/find_doc.go b/internal/shortcut/smart/find_doc.go new file mode 100644 index 00000000..ca9d4f30 --- /dev/null +++ b/internal/shortcut/smart/find_doc.go @@ -0,0 +1,146 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FindDoc: search cloud documents by keyword and project the essentials. +// +// This is a one-step convenience wrapper over the doc MCP tool search_documents. +// It mirrors the helpers doc search path exactly: +// - keyword ← --query (the free-text search term; MCP arg "keyword", verbatim) +// - pageSize ← --limit (optional; MCP arg "pageSize", verbatim) +// +// The raw search response is then reduced locally to a compact list of +// {title, url, type, token} per hit and emitted via rt.Output, so it honours +// the root --format/--jq/--fields projection flags. Field parsing is +// defensive: both the container (result/data/list/items/documents/docs) and +// each item's fields (multiple candidate keys) are probed leniently. +// +// Read-only: it never mutates anything, it only searches and projects locally. +// +// dws doc +find-doc --query 季度汇报 +var FindDoc = shortcut.Shortcut{ + Service: "doc", + Command: "+find-doc", + Product: "doc", + Description: "按关键词搜索云文档并投影关键字段(只读)", + Intent: "当你只记得云文档标题或内容里的某个关键词,想快速按关键词找到匹配的文档、拿到它的标题、URL、类型和 token 以便后续查看或编辑," + + "却不想拿到一大坨原始字段时使用;内部调用云文档的 search_documents 工具,把 --query 作为搜索关键词(keyword)," + + "可选地用 --limit 限制返回条数(pageSize),再在本地把每条命中结果精简为「标题、URL、类型、token」四个字段后打印。" + + "这是纯只读操作,只做搜索与本地投影,不会创建、修改或删除任何文档;未命中时提示「没搜到文档」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "按关键词搜索云文档(必填)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "限制返回的文档条数(可选)"}, + }, + Tips: []string{ + `dws doc +find-doc --query 季度汇报`, + `dws doc +find-doc --query 合同 --limit 10`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("query") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // keyword / pageSize mirror the helpers doc search_documents path verbatim. + params := map[string]any{ + "keyword": rt.Str("query"), + } + if rt.Changed("limit") { + if n := rt.Int("limit"); n > 0 { + params["pageSize"] = n + } + } + + data, err := rt.CallMCPData("doc", "search_documents", params) + if err != nil { + return err + } + + items := shortcutFindDocItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "title": shortcutFindDocStr(m, "title", "name", "docName", "subject"), + "url": shortcutFindDocStr(m, "url", "docUrl", "link", "webUrl"), + "type": shortcutFindDocStr(m, "docType", "type", "dentryType", "fileType"), + "token": shortcutFindDocStr(m, "docId", "token", "nodeId", "dentryId", "id"), + }) + } + + if len(results) == 0 { + return apperrors.NewValidation("没搜到文档") + } + + return rt.Output(map[string]any{"documents": results, "count": len(results)}) + }, +} + +// shortcutFindDocItems locates the list of hit records inside the +// search_documents response, tolerating an optional result/data wrapper and +// several common list key names. Returns a slice of map records (non-map +// entries are skipped). +func shortcutFindDocItems(data map[string]any) []map[string]any { + container := data + for _, wrap := range []string{"result", "data"} { + if inner, ok := container[wrap].(map[string]any); ok { + container = inner + } + } + for _, key := range []string{"documents", "docs", "items", "list", "results", "records", "nodes"} { + if arr, ok := container[key].([]any); ok { + return shortcutFindDocToMaps(arr) + } + } + // Fallback: the container itself might hold the array under a differently + // named key — scan for the first []any value. + for _, v := range container { + if arr, ok := v.([]any); ok { + return shortcutFindDocToMaps(arr) + } + } + return nil +} + +func shortcutFindDocToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, e := range arr { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutFindDocStr returns the first non-empty string value among the given +// candidate keys. +func shortcutFindDocStr(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + } + return "" +} + +func init() { + shortcut.Register(FindDoc) +} diff --git a/internal/shortcut/smart/find_file.go b/internal/shortcut/smart/find_file.go new file mode 100644 index 00000000..c822bafb --- /dev/null +++ b/internal/shortcut/smart/find_file.go @@ -0,0 +1,159 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FindFile: search 钉盘 files by name keyword and project the essentials. +// +// This is a one-step convenience wrapper over the drive MCP tool search_files. +// It mirrors helpers driveSearchCmd exactly for the "仅搜钉盘文件" path: +// - keyword ← --query (the free-text file-name search term; MCP arg "keyword") +// - searchTarget ← "file" (restrict to 钉盘 files/folders, no doc-space aggregation) +// +// The raw search response is then reduced locally to a compact list of +// {name, type, dentryId, fileSize} per hit and emitted via rt.Output, so it +// honours the root --format/--jq/--fields projection flags. Field parsing is +// defensive: both the container (result/data/items/files/nodes/list) and each +// item's fields (multiple candidate keys) are probed leniently. +// +// Read-only: it never mutates anything, it only searches and projects locally. +// +// dws drive +find-file --query 季度汇报 +var FindFile = shortcut.Shortcut{ + Service: "drive", + Command: "+find-file", + Product: "drive", + Description: "按名称关键词搜索钉盘文件并投影关键字段(只读)", + Intent: "当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看," + + "却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) " + + "并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。" + + "这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "文件名关键词(必填)", Required: true}, + }, + Tips: []string{ + `dws drive +find-file --query 季度汇报`, + `dws drive +find-file --query 合同`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("flag --query is required") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("query")) + + // keyword / searchTarget mirror helpers driveSearchCmd (search_files), + // restricted to 钉盘 files only. + data, err := rt.CallMCPData("drive", "search_files", map[string]any{ + "keyword": keyword, + "searchTarget": "file", + }) + if err != nil { + return err + } + + items := shortcutFindFileItems(data) + files := make([]map[string]any, 0, len(items)) + for _, m := range items { + files = append(files, map[string]any{ + "name": shortcutFindFileStr(m, "name", "fileName", "title", "dentryName"), + "type": shortcutFindFileStr(m, "type", "dentryType", "extension", "fileType"), + "dentryId": shortcutFindFileStr(m, "dentryId", "dentryUuid", "fileId", "nodeId", "id"), + "fileSize": shortcutFindFileSize(m), + }) + } + + return rt.Output(map[string]any{"files": files}) + }, +} + +// shortcutFindFileItems locates the list of hit records inside the search_files +// response, tolerating an optional result/data wrapper and several common list +// key names. Returns a slice of map records (non-map entries are skipped). +func shortcutFindFileItems(data map[string]any) []map[string]any { + container := data + for _, wrap := range []string{"result", "data"} { + if inner, ok := container[wrap].(map[string]any); ok { + container = inner + } + } + for _, key := range []string{"items", "files", "nodes", "list", "dentries", "results", "records"} { + if arr, ok := container[key].([]any); ok { + return shortcutFindFileToMaps(arr) + } + } + // Fallback: the container itself might be the array under a differently + // named key — scan for the first []any value. + for _, v := range container { + if arr, ok := v.([]any); ok { + return shortcutFindFileToMaps(arr) + } + } + return nil +} + +func shortcutFindFileToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, e := range arr { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutFindFileStr returns the first non-empty string value among the given +// candidate keys. +func shortcutFindFileStr(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + } + return "" +} + +// shortcutFindFileSize reads a hit's file size, tolerating numeric (float64) and +// string JSON encodings across the common size key names. Returns nil when +// absent (e.g. folders), so the field is simply omitted-as-null in output. +func shortcutFindFileSize(m map[string]any) any { + for _, k := range []string{"fileSize", "size"} { + switch v := m[k].(type) { + case float64: + return int64(v) + case int64: + return v + case string: + if strings.TrimSpace(v) != "" { + return v + } + } + } + return nil +} + +func init() { + shortcut.Register(FindFile) +} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go new file mode 100644 index 00000000..fb9d1c3a --- /dev/null +++ b/internal/shortcut/smart/find_mail_user.go @@ -0,0 +1,158 @@ +// 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 ( + "strconv" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FindMailUser: search mailbox users by keyword (name / nickname / email +// fragment) and project a compact contact list in one step. +// +// Steps: +// +// 1. call search_mail_users with keyword=--query (and size=--limit when +// given). The tool name, server ("mail") and the "keyword"/"size" argument +// keys are taken verbatim from helpers.mail.go's `mail user search` command +// (callMCPTool("search_mail_users", …) with toolArgs["keyword"]/["size"]); +// +// 2. in Go, defensively unwrap the user list and project each entry to +// {name, nickname, email, employeeNo, jobTitle, workLocation, id} — field +// parsing probes several candidate keys — and print via rt.Output so it +// honours --format/--jq/--fields; +// +// 3. if nothing matched, report "没搜到邮箱联系人" instead of an empty raw dump. +// +// Read-only: it only searches and reshapes, never mutating anything. +// +// Note: per the helper docs this only works for enterprise mailboxes (not +// @dingtalk.com personal mailboxes). +// +// dws mail +find-mail-user --query "张三" +// dws mail +find-mail-user --query alice --limit 10 +var FindMailUser = shortcut.Shortcut{ + Service: "mail", + Command: "+find-mail-user", + Product: "mail", + Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", + Intent: "当你只知道某人的姓名、花名或邮箱片段,想在企业邮箱通讯录里按关键词把匹配的邮箱用户找出来、" + + "并只看一份精简清单(姓名、昵称、邮箱地址、工号、职位、工作地)而不想拿到一大坨原始字段时使用;" + + "内部按 --query 关键词调用邮箱用户搜索,再在本地把每个匹配用户投影成整洁记录打印出来,可配合 --format/--jq/--fields。" + + "这是纯只读操作,只做搜索与本地投影,不会修改任何数据;" + + "注意仅企业邮箱可用(个人邮箱如 xxx@dingtalk.com 会因无权限报错);若没有命中则提示「没搜到邮箱联系人」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false}, + }, + Tips: []string{ + `dws mail +find-mail-user --query "张三"`, + `dws mail +find-mail-user --query alice --limit 10`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("query") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — search mailbox users. keyword/size mirror the toolArgs built in + // helpers.mail.go's `mail user search` command; size is passed as a string. + args := map[string]any{"keyword": rt.Str("query")} + if rt.Changed("limit") { + args["size"] = strconv.Itoa(rt.Int("limit")) + } + data, err := rt.CallMCPData("mail", "search_mail_users", args) + if err != nil { + return err + } + + // Step 2 — project matched users. + users := findMailUserUnwrap(data) + results := make([]map[string]any, 0, len(users)) + for _, u := range users { + results = append(results, map[string]any{ + "name": findMailUserString(u, "name", "displayName", "userName", "nick"), + "nickname": findMailUserString(u, "nickname", "nick", "displayName"), + "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"), + "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"), + "jobTitle": findMailUserString(u, "jobTitle", "title", "position"), + "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"), + "id": findMailUserAny(u, "id", "userId", "userid"), + }) + } + + // Step 3 — empty result guard. + if len(results) == 0 { + return apperrors.NewValidation("没搜到邮箱联系人") + } + + return rt.Output(map[string]any{"users": results, "count": len(results)}) + }, +} + +// findMailUserUnwrap extracts the user list from a search_mail_users response. +// The helper documents the list under "users"; we probe several container keys +// at the top level and one level deep, defensively. +func findMailUserUnwrap(data map[string]any) []map[string]any { + keys := []string{"users", "contacts", "result", "data", "list", "items", "records"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return findMailUserToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range keys { + if arr, ok := inner[k2].([]any); ok { + return findMailUserToMaps(arr) + } + } + } + } + return nil +} + +func findMailUserToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +func findMailUserString(m map[string]any, keys ...string) string { + for _, key := range keys { + if s, ok := m[key].(string); ok && s != "" { + return s + } + } + return "" +} + +func findMailUserAny(m map[string]any, keys ...string) any { + for _, key := range keys { + if v, ok := m[key]; ok && v != nil { + if s, isStr := v.(string); isStr && s == "" { + continue + } + return v + } + } + return nil +} + +func init() { + shortcut.Register(FindMailUser) +} diff --git a/internal/shortcut/smart/find_record.go b/internal/shortcut/smart/find_record.go new file mode 100644 index 00000000..ece8621d --- /dev/null +++ b/internal/shortcut/smart/find_record.go @@ -0,0 +1,71 @@ +// 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 ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FindRecord: search records in a given aitable (多维表) by a full-text keyword. +// +// This is a one-step convenience wrapper over query_records. It mirrors +// helpers RecordQuery exactly: +// - baseId ← --base +// - tableId ← --table +// - keyword ← --query (the free-text search term; MCP arg is "keyword") +// +// When --query is omitted it simply returns the first page of records for the +// table (no filter). It is a read-only operation and never mutates data. +// +// dws aitable +find-record --base B --table T +// dws aitable +find-record --base B --table T --query 张三 +var FindRecord = shortcut.Shortcut{ + Service: "aitable", + Command: "+find-record", + Product: "aitable", + Description: "在指定多维表里按关键词查记录(只读)", + Intent: "当你已经知道某个多维表的 baseId 和 tableId、想按一个关键词快速找出匹配的行记录," + + "却不想手写结构化过滤条件时使用;内部直接调用 query_records,把 --query 作为全文关键词(keyword)在该表里检索并打印匹配记录。" + + "不传 --query 时则返回该表的前若干条记录。这是只读操作,不会修改任何数据。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(多维表所属 base)", Required: true}, + {Name: "table", Type: shortcut.FlagString, Desc: "Table ID(要检索的数据表)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "全文关键词(可选,不填则取前若干条)", Required: false}, + }, + Tips: []string{ + `dws aitable +find-record --base B --table T`, + `dws aitable +find-record --base B --table T --query 张三`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("base", "table") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // baseId / tableId / keyword mirror helpers RecordQuery (query_records). + params := map[string]any{ + "baseId": rt.Str("base"), + "tableId": rt.Str("table"), + } + if kw := strings.TrimSpace(rt.Str("query")); kw != "" { + params["keyword"] = kw + } + return rt.CallMCP("query_records", params) + }, +} + +func init() { + shortcut.Register(FindRecord) +} diff --git a/internal/shortcut/smart/find_room.go b/internal/shortcut/smart/find_room.go new file mode 100644 index 00000000..cf210524 --- /dev/null +++ b/internal/shortcut/smart/find_room.go @@ -0,0 +1,187 @@ +// 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" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FindRoom: list meeting rooms that are AVAILABLE within a given time window. +// +// Steps: +// +// 1. parse the ISO8601 --start/--end into epoch millis, exactly as the +// calendar room-search tool expects (startTime/endTime are int64 millis, +// mirroring helpers.roomSearch Mode 2); +// +// 2. call query_available_meeting_room with {startTime, endTime} — the same +// MCP tool + parameter names used by `calendar room search` availability +// mode (see helpers/calendar.go callMeetingRoomSearchResult); +// +// 3. defensively project each returned room to {roomId, name, capacity} and +// print the list via rt.Output so it honours --format/--jq/--fields. +// +// Read-only: it only queries availability, it never books or mutates anything. +// +// dws calendar +find-room --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" +var FindRoom = shortcut.Shortcut{ + Service: "calendar", + Command: "+find-room", + Product: "calendar", + Description: "查询指定时间段内所有可用的会议室", + Intent: "当你想在某个明确的时间段内找出所有当前可预定的空闲会议室(比如临时要约线下会、先看看哪些会议室有空)时使用;" + + "内部把你给的 ISO8601 起止时间解析成毫秒时间戳,调用会议室可用性查询,只返回该时间范围内可预定的会议室," + + "并投影出每个会议室的 roomId、名称与容量,方便你随后用来预订。" + + "这是纯只读操作,只做可用性查询,不会预订或改动任何会议室或日程;" + + "注意大部分会议室仅在工作时间可用,非工作时间可能查不到结果,且 start 需为未来时间。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间(ISO8601,如 2026-03-10T14:00:00+08:00,需为未来时间)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间(ISO8601,如 2026-03-10T15:00:00+08:00)", Required: true}, + }, + Tips: []string{ + `dws calendar +find-room --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — parse the ISO8601 range to epoch millis. The room-search + // availability tool expects startTime/endTime as int64 millis, exactly + // like helpers.roomSearch (parseISOTimeToMillis). + startMillis, err := findRoomParseMillis("start", rt.Str("start")) + if err != nil { + return err + } + endMillis, err := findRoomParseMillis("end", rt.Str("end")) + if err != nil { + return err + } + if endMillis <= startMillis { + return apperrors.NewValidation("--end 必须晚于 --start") + } + + // Step 2 — query available meeting rooms over the range. tool name + + // param names copied verbatim from helpers callMeetingRoomSearchResult + // (query_available_meeting_room) / roomSearch Mode 2 toolArgs. + data, err := rt.CallMCPData("calendar", "query_available_meeting_room", map[string]any{ + "startTime": startMillis, + "endTime": endMillis, + }) + if err != nil { + return err + } + + // Step 3 — project each returned room to {roomId, name, capacity}. + rooms := make([]map[string]any, 0) + for _, m := range findRoomExtractRooms(data) { + rooms = append(rooms, map[string]any{ + "roomId": findRoomFirstString(m, "roomId", "roomID", "id", "room_id"), + "name": findRoomFirstString(m, "roomName", "name", "title", "displayName"), + "capacity": findRoomCapacity(m), + }) + } + + return rt.Output(map[string]any{"rooms": rooms}) + }, +} + +// findRoomParseMillis parses an ISO8601 timestamp into epoch milliseconds, +// returning a clear validation error naming the offending flag. +func findRoomParseMillis(flag, value string) (int64, error) { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf( + "--%s 时间格式无效:%q,请使用 ISO8601(如 2026-03-10T14:00:00+08:00)", flag, value)) + } + return t.UnixMilli(), nil +} + +// findRoomExtractRooms defensively pulls the room list out of a +// query_available_meeting_room response, tolerating several common shapes: +// the list may sit directly under result, or be nested under a rooms-like key +// inside a result object, or live at the top level. +func findRoomExtractRooms(data map[string]any) []map[string]any { + if data == nil { + return nil + } + // Candidate containers to probe, in priority order. + containers := []any{data["result"], data["data"], data} + listKeys := []string{"rooms", "roomList", "meetingRooms", "list", "items", "records"} + + for _, c := range containers { + switch v := c.(type) { + case []any: + if out := findRoomToMaps(v); len(out) > 0 { + return out + } + case map[string]any: + for _, k := range listKeys { + if arr, ok := v[k].([]any); ok { + if out := findRoomToMaps(arr); len(out) > 0 { + return out + } + } + } + } + } + return nil +} + +// findRoomToMaps keeps only the map elements of a JSON array. +func findRoomToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, e := range arr { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// findRoomFirstString returns the first non-empty string value among the given +// candidate keys. +func findRoomFirstString(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} + +// findRoomCapacity reads a room's seating capacity, tolerating numeric +// (float64/int) and string JSON encodings across a few common key names. +func findRoomCapacity(m map[string]any) any { + for _, k := range []string{"capacity", "maxCapacity", "seatCount", "seats"} { + switch v := m[k].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + if v != "" { + return v + } + } + } + return nil +} + +func init() { + shortcut.Register(FindRoom) +} diff --git a/internal/shortcut/smart/free_slots.go b/internal/shortcut/smart/free_slots.go new file mode 100644 index 00000000..f4ace9cb --- /dev/null +++ b/internal/shortcut/smart/free_slots.go @@ -0,0 +1,143 @@ +// 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" + "sort" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// FreeSlots: find the OPEN gaps in my day — "when can I fit a meeting?" — the +// complement of +conflicts. Not offered by the raw 1:1 layer. +// +// It lists list_calendar_events for the target day (default today, --in-days N +// ahead), merges the busy intervals, and reports the free windows inside a +// working-hours range (default 09:00–18:00, override with --from/--to hour). +// Read-only. +// +// dws calendar +free-slots +// dws calendar +free-slots --in-days 1 --from 9 --to 20 +var FreeSlots = shortcut.Shortcut{ + Service: "calendar", + Command: "+free-slots", + Product: "calendar", + Description: "找我某天工作时段内的空闲时间段(默认今天 09:00-18:00)", + Intent: "当你想知道『我今天(或某天)还有哪些时间是空的、可以安排会议/事情』时使用;" + + "内部列出目标日期(默认今天,--in-days 指定几天后)的全部日程,合并忙碌时段," + + "再在工作时间范围内(默认 09:00-18:00,可用 --from/--to 指定起止小时)算出所有空闲窗口并给出每段的起止与时长。" + + "只读操作,不修改任何日程;用于快速定位可预约的空档。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "in-days", Type: shortcut.FlagInt, Desc: "几天后(可选,0=今天默认)", Required: false}, + {Name: "from", Type: shortcut.FlagInt, Desc: "工作时段起始小时(可选,默认 9)", Required: false}, + {Name: "to", Type: shortcut.FlagInt, Desc: "工作时段结束小时(可选,默认 18)", Required: false}, + }, + Tips: []string{ + `dws calendar +free-slots`, + `dws calendar +free-slots --in-days 1 --from 9 --to 20`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + offset := rt.Int("in-days") + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, offset) + + fromHour, toHour := 9, 18 + if rt.Changed("from") { + fromHour = rt.Int("from") + } + if rt.Changed("to") { + toHour = rt.Int("to") + } + if fromHour < 0 || fromHour > 23 || toHour < 1 || toHour > 24 || toHour <= fromHour { + return apperrors.NewValidation("--from/--to 需满足 0<=from --start ... --end ...`. +// +// dws calendar +free --who 张三 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" +var FreeBusy = shortcut.Shortcut{ + Service: "calendar", + Command: "+free", + Product: "calendar", + Description: "按姓名查询某人在指定时间段内的忙闲状态(自动解析 userId)", + Intent: "当你只知道对方姓名、想知道 TA 在某段时间内是空闲还是被日程占用(比如约会前先看看有没有空)而不想先手动查 userId 时使用;" + + "内部先按姓名搜通讯录解析出唯一 userId,姓名匹配到多人时会列出候选让你区分,再按时间范围查询忙闲。只读,不产生任何日程变更。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "who", Type: shortcut.FlagString, Desc: "要查忙闲的人的姓名/花名", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间(ISO8601,如 2026-03-10T14:00:00+08:00)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间(ISO8601,如 2026-03-10T18:00:00+08:00)", Required: true}, + }, + Tips: []string{`dws calendar +free --who 张三 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the person's name to a unique userId. + user, err := resolveUser(rt, rt.Str("who")) + if err != nil { + return err + } + + // Step 2 — parse the ISO8601 range to epoch millis, exactly as the + // calendar busy tool expects (startTime/endTime are int64 millis). + startMillis, err := freebusyParseMillis("start", rt.Str("start")) + if err != nil { + return err + } + endMillis, err := freebusyParseMillis("end", rt.Str("end")) + if err != nil { + return err + } + if endMillis <= startMillis { + return apperrors.NewValidation("--end 必须晚于 --start") + } + + // Step 3 — query busy status for that single user over the range and + // project the verbose result[].scheduleItems[].{start,end}.dateTime + // structure down to a flat list of {start,end} busy slots. + data, err := rt.CallMCPData("calendar", "query_busy_status", map[string]any{ + "startTime": startMillis, + "endTime": endMillis, + "userIds": []string{user.userID}, + }) + if err != nil { + return err + } + busy := freebusySlots(data) + return rt.Output(map[string]any{ + "who": user.name, + "userId": user.userID, + "busy": busy, + "free": len(busy) == 0, + }) + }, +} + +// freebusySlots flattens a query_busy_status response (result[] → scheduleItems[] +// → {start,end}.dateTime) into a flat list of {start,end} busy slots. +func freebusySlots(data map[string]any) []map[string]any { + out := []map[string]any{} + entries, _ := data["result"].([]any) + for _, e := range entries { + em, ok := e.(map[string]any) + if !ok { + continue + } + items, _ := em["scheduleItems"].([]any) + for _, it := range items { + im, ok := it.(map[string]any) + if !ok { + continue + } + out = append(out, map[string]any{ + "start": freebusyDateTime(im["start"]), + "end": freebusyDateTime(im["end"]), + }) + } + } + return out +} + +// freebusyDateTime pulls the readable timestamp out of a {date,dateTime,timeZone} +// object, falling back to the raw value. +func freebusyDateTime(v any) any { + if m, ok := v.(map[string]any); ok { + if dt, ok := m["dateTime"]; ok && dt != nil { + return dt + } + if d, ok := m["date"]; ok && d != nil { + return d + } + } + return v +} + +// freebusyParseMillis parses an ISO8601 timestamp into epoch milliseconds, +// returning a clear validation error naming the offending flag. +func freebusyParseMillis(flag, value string) (int64, error) { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf( + "--%s 时间格式无效:%q,请使用 ISO8601(如 2026-03-10T14:00:00+08:00)", flag, value)) + } + return t.UnixMilli(), nil +} + +func init() { + shortcut.Register(FreeBusy) +} diff --git a/internal/shortcut/smart/group_members.go b/internal/shortcut/smart/group_members.go new file mode 100644 index 00000000..1f43cdd0 --- /dev/null +++ b/internal/shortcut/smart/group_members.go @@ -0,0 +1,147 @@ +// 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" +) + +// 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 `. +// +// 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: "按群名列出群成员(自动搜群解析 openConversationId)", + Intent: "当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;" + + "内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。" + + "群名匹配到多个群时会列出候选让你区分、绝不自行假定。只读,不改动任何数据。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true}, + }, + Tips: []string{`dws chat +group-members --group 项目冲刺`}, + Execute: func(rt *shortcut.RuntimeContext) error { + groupName := rt.Str("group") + + // Step 1 — search groups by name (keyword) on the im server. + data, err := rt.CallMCPData("im", "search_groups", map[string]any{ + "keyword": groupName, + "limit": 10, + "cursor": "0", + }) + if err != nil { + return err + } + groups := extractGroupsForSend(data) + switch { + case len(groups) == 0: + return apperrors.NewValidation(fmt.Sprintf( + "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) + case len(groups) > 1: + return apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat group members --id 指定群。", + groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) + } + + // Step 2 — list the members of the unique group. The param key + // (openconversation_id) is copied verbatim from chat.go's + // get_group_members call site. Project the verbose raw member records + // (memberAvatarMediaId, memberDingtalkId, …) down to the useful fields. + mdata, err := rt.CallMCPData("chat", "get_group_members", map[string]any{ + "openconversation_id": groups[0].id, + }) + if err != nil { + return err + } + members := groupMemberProject(mdata) + if len(members) == 0 { + // Unrecognised shape — fall back to the raw payload rather than + // hiding data. + return rt.Output(mdata) + } + return rt.Output(map[string]any{"count": len(members), "members": members}) + }, +} + +// 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) +} diff --git a/internal/shortcut/smart/invite.go b/internal/shortcut/smart/invite.go new file mode 100644 index 00000000..36e564a6 --- /dev/null +++ b/internal/shortcut/smart/invite.go @@ -0,0 +1,82 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Invite: add people BY NAME as participants to an EXISTING calendar event. +// +// Steps: resolve every name in --with to a unique userId, then batch-add them +// all to the --event day's event as attendees. Replaces the manual flow of +// `contact +search-user` (copy each userId) → `calendar attendee add`. +// +// dws calendar +invite --event EVENT_ID --with 张三,李四 +var Invite = shortcut.Shortcut{ + Service: "calendar", + Command: "+invite", + Product: "calendar", + Description: "按姓名把参会人加入已有日程(自动解析 userId 后批量添加)", + Intent: "当你已经有一个日程(知道 eventId),想按姓名把几位同事拉进来当参会人时使用;" + + "内部先把 --with 里每个姓名解析成唯一 userId,再一次性把他们全部加到 --event 指定的日程里。" + + "会真实修改日程并发出参会邀请。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "event", Type: shortcut.FlagString, Desc: "已有日程的 eventId", Required: true}, + {Name: "with", Type: shortcut.FlagString, Desc: "参会人姓名,逗号分隔", Required: true}, + }, + Tips: []string{ + `dws calendar +invite --event EVENT_ID --with 张三`, + `dws calendar +invite --event EVENT_ID --with 张三,李四`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + eventID := strings.TrimSpace(rt.Str("event")) + if eventID == "" { + return apperrors.NewValidation("--event 需要一个有效的日程 eventId") + } + + // Resolve every participant name to a unique userId first, so an + // unknown/ambiguous name fails before we touch the event. + var userIDs []string + for _, name := range strings.Split(rt.Str("with"), ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + user, err := resolveUser(rt, name) + if err != nil { + return err + } + userIDs = append(userIDs, user.userID) + } + if len(userIDs) == 0 { + return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名") + } + + // Batch-add all participants. eventId + attendeesToAdd copied verbatim + // from the helper's `attendee add` call site (add_calendar_participant). + return rt.CallMCP("add_calendar_participant", map[string]any{ + "eventId": eventID, + "attendeesToAdd": userIDs, + }) + }, +} + +func init() { + shortcut.Register(Invite) +} diff --git a/internal/shortcut/smart/latest_minutes.go b/internal/shortcut/smart/latest_minutes.go new file mode 100644 index 00000000..5143ee95 --- /dev/null +++ b/internal/shortcut/smart/latest_minutes.go @@ -0,0 +1,165 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// LatestMinutes: fetch the details of MY most recent minutes (听记) in one step. +// +// Steps: +// 1. list my minutes via list_by_keyword_and_time_range (belongingConditionId +// = "created"), optionally filtered by --keyword; +// 2. pick the newest entry (largest create time, falling back to the first +// item) and read its taskUuid; +// 3. print that minute's basic info via get_minutes_basic_info. +// +// If the list is empty it reports "暂无妙记" instead of failing obscurely. +// +// dws minutes +latest-minutes +// dws minutes +latest-minutes --keyword 周会 +var LatestMinutes = shortcut.Shortcut{ + Service: "minutes", + Command: "+latest-minutes", + Product: "minutes", + Description: "取我最新的一条妙记(听记)详情", + Intent: "当你只想快速看回自己最近的一条会议听记,却不想先翻列表、复制 taskUuid 再查详情时使用;" + + "内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的基础信息(标题、创建人、时间、访问链接等)。" + + "这是只读操作,不会修改任何听记;若你名下没有任何听记则提示「暂无妙记」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false}, + }, + Tips: []string{ + `dws minutes +latest-minutes`, + `dws minutes +latest-minutes --keyword 周会`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my minutes (newest first). belongingConditionId / + // maxResults / keyword mirror helpers.callListByKeywordRange. + listArgs := map[string]any{ + "belongingConditionId": "created", + "maxResults": float64(20), + } + if kw := rt.Str("keyword"); kw != "" { + listArgs["keyword"] = kw + } + data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs) + if err != nil { + return err + } + + // Step 2 — locate the newest minute's taskUuid. + taskUUID := latestMinutesTaskUUID(data) + if taskUUID == "" { + return apperrors.NewValidation("暂无妙记") + } + + // Step 3 — print its basic info (taskUuid param mirrors helper). + return rt.CallMCP("get_minutes_basic_info", map[string]any{ + "taskUuid": taskUUID, + }) + }, +} + +// latestMinutesItems walks a list_by_keyword_and_time_range response and returns +// its minutes entries. The gateway wraps the list under one of several common +// container keys, so we probe them defensively before scanning for a bare list. +func latestMinutesItems(data map[string]any) []map[string]any { + for _, key := range []string{"result", "list", "minutesList", "items", "data", "records"} { + if arr, ok := data[key].([]any); ok { + return latestMinutesToMaps(arr) + } + // Some responses nest the list one level deeper, e.g. {"data": {"list": [...]}}. + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "minutesList", "items", "records", "result"} { + if arr, ok := inner[k2].([]any); ok { + return latestMinutesToMaps(arr) + } + } + } + } + return nil +} + +func latestMinutesToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// latestMinutesTaskUUID picks the newest minute's taskUuid: the item with the +// largest numeric create time, falling back to the first item that carries a +// taskUuid (lists come back newest-first). +func latestMinutesTaskUUID(data map[string]any) string { + items := latestMinutesItems(data) + if len(items) == 0 { + return "" + } + + best := "" + var bestTime float64 + haveTime := false + firstUUID := "" + + for _, m := range items { + uuid := latestMinutesUUID(m) + if uuid == "" { + continue + } + if firstUUID == "" { + firstUUID = uuid + } + if t, ok := latestMinutesCreateTime(m); ok { + if !haveTime || t > bestTime { + haveTime = true + bestTime = t + best = uuid + } + } + } + + if haveTime && best != "" { + return best + } + return firstUUID +} + +func latestMinutesUUID(m map[string]any) string { + for _, key := range []string{"taskUuid", "taskUUID", "uuid", "id"} { + if s, ok := m[key].(string); ok && s != "" { + return s + } + } + return "" +} + +func latestMinutesCreateTime(m map[string]any) (float64, bool) { + for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} { + if v, ok := m[key].(float64); ok { + return v, true + } + } + return 0, false +} + +func init() { + shortcut.Register(LatestMinutes) +} diff --git a/internal/shortcut/smart/list_tables.go b/internal/shortcut/smart/list_tables.go new file mode 100644 index 00000000..54231b6b --- /dev/null +++ b/internal/shortcut/smart/list_tables.go @@ -0,0 +1,155 @@ +// 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 ( + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ListTables: list every data table (数据表) inside one multi-dimensional +// table (base) in a single step. +// +// This is a read-only convenience wrapper over get_tables. It mirrors helpers +// tableGetCmd exactly: it calls the "aitable" server tool "get_tables" with a +// single argument, baseId ← --base (no tableIds → the server returns all tables +// in the base). The response's table list is then defensively projected down to +// {tableId, tableName} and printed via rt.Output so it honours --format/--jq/ +// --fields. When no recognisable table list is found it falls back to printing +// the raw payload. +// +// dws aitable +list-tables --base B +var ListTables = shortcut.Shortcut{ + Service: "aitable", + Command: "+list-tables", + Product: "aitable", + Description: "列出某个多维表(base)里的所有数据表(只读,投影 tableId/tableName)", + Intent: "当你已经知道某个多维表(base)的 baseId、想一步看清这个 base 下都有哪些数据表(table)、" + + "拿到它们的 tableId 和 tableName 以便后续查记录或改结构,却不想手动翻 base get 的完整目录时使用;" + + "内部直接调用 get_tables,只传 baseId(不带 tableIds,因此返回该 base 下的全部数据表)," + + "再在本地把每张表投影成 tableId、tableName 两个关键字段打印出来。" + + "这是纯只读操作,只做列举与本地投影,不会创建、修改或删除任何表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(要列出数据表的多维表)", Required: true}, + }, + Tips: []string{ + `dws aitable +list-tables --base B`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("base") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // baseId mirrors helpers tableGetCmd (get_tables). Omitting tableIds + // asks the server for every table in the base. + data, err := rt.CallMCPData("aitable", "get_tables", map[string]any{ + "baseId": rt.Str("base"), + }) + if err != nil { + return err + } + + // Project each table down to {tableId, tableName}; fall back to the raw + // payload when we cannot locate a recognisable table list. + items := listTablesItems(data) + if len(items) == 0 { + return rt.Output(data) + } + results := make([]map[string]any, 0, len(items)) + for _, t := range items { + results = append(results, map[string]any{ + "tableId": listTablesID(t), + "tableName": listTablesName(t), + }) + } + return rt.Output(map[string]any{"tables": results}) + }, +} + +// listTablesItems locates the tables list inside a get_tables response, probing +// common container keys at the top level and nested under "data"/"result". +// Returns nil when no list is found. +func listTablesItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + keys := []string{"tables", "list", "items", "records", "data", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return listTablesToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"tables", "list", "items", "records"} { + if arr, ok := inner[k2].([]any); ok { + return listTablesToMaps(arr) + } + } + } + } + return nil +} + +func listTablesToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// listTablesID reads a table's id, tolerating the common id key variants. +func listTablesID(t map[string]any) any { + for _, key := range []string{"tableId", "table_id", "id"} { + if v := listTablesString(t[key]); v != "" { + return v + } + } + return nil +} + +// listTablesName reads a table's display name, tolerating the common name key +// variants. +func listTablesName(t map[string]any) any { + for _, key := range []string{"tableName", "table_name", "name", "title"} { + if v := listTablesString(t[key]); v != "" { + return v + } + } + return nil +} + +// listTablesString coerces a scalar JSON value to a trimmed string, returning "" +// for nil / non-scalar / empty values. +func listTablesString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func init() { + shortcut.Register(ListTables) +} diff --git a/internal/shortcut/smart/lookup.go b/internal/shortcut/smart/lookup.go new file mode 100644 index 00000000..3e03c83b --- /dev/null +++ b/internal/shortcut/smart/lookup.go @@ -0,0 +1,55 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Lookup: resolve a person by NAME and return their full profile in one step. +// +// Steps: search contacts by name → resolve to a single userId (disambiguate on +// multiple matches) → fetch full detail. Replaces `contact +search-user` (copy +// userId) → `contact +get-user --ids `. +// +// dws contact +lookup 张三 +var Lookup = shortcut.Shortcut{ + Service: "contact", + Command: "+lookup", + Product: "contact", + Description: "按姓名查询某人的完整资料(自动解析 userId 后取详情)", + Intent: "当你只知道对方姓名、想一步拿到其完整资料(部门、职位、联系方式等)而不想先搜 userId 再查详情时使用;" + + "内部先按姓名搜通讯录解析出唯一 userId,再取详情,姓名匹配到多人时会列出候选让你区分。只读操作。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "姓名/花名", Required: true}, + }, + Tips: []string{`dws contact +lookup 张三`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the name to a unique userId. + user, err := resolveUser(rt, rt.Str("name")) + if err != nil { + return err + } + + // Step 2 — fetch and print the full profile of the resolved user. + return rt.CallMCP("get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{user.userID}, + }) + }, +} + +func init() { + shortcut.Register(Lookup) +} diff --git a/internal/shortcut/smart/minutes_detail.go b/internal/shortcut/smart/minutes_detail.go new file mode 100644 index 00000000..ce7bcc8a --- /dev/null +++ b/internal/shortcut/smart/minutes_detail.go @@ -0,0 +1,128 @@ +// 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" +) + +// MinutesDetail: fetch several artifacts of ONE minute (听记) in a single command +// and print them as one projected bundle. +// +// dws exposes each artifact as its own atomic tool (get_minutes_basic_info / +// get_minutes_ai_summary / get_minutes_keywords / get_minutes_transcription / +// list_minutes_todos). To assemble a full picture a user otherwise has to call +// 4–5 commands and stitch the taskUuid through each. This shortcut fans them out +// for one taskUuid, tolerates partial failure (a failing artifact is recorded as +// an error string rather than aborting the whole bundle) and projects the result +// through rt.Output so it honours --format/--jq/--fields. +// +// --artifacts selects which artifacts to pull (default: all). Each tool's params +// mirror the helper call sites in internal/helpers/minutes.go: every one takes a +// single "taskUuid", and transcription additionally takes "direction". +// +// dws minutes +detail --id +// dws minutes +detail --id --artifacts summary,todos +// dws minutes +detail --id --direction 1 +var MinutesDetail = shortcut.Shortcut{ + Service: "minutes", + Command: "+detail", + Product: "minutes", + Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)", + Intent: "当你已经有某条听记的 taskUuid,想在一次操作里同时拿到它的基础信息、AI 摘要、关键词、逐字稿和待办,而不想分别敲 4~5 个子命令再自己拼时使用;" + + "内部按 --artifacts 选择要拉的产物(默认全部:basic/summary/keywords/transcript/todos),逐个调用对应的原子工具并聚合成一个结果," + + "某一项失败不会中断整体(会以错误字符串记录在该项下)。这是纯只读操作,不会修改听记;--direction 仅影响逐字稿排序(0=正序默认,1=倒序)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true}, + {Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集: basic,summary,keywords,transcript,todos(默认全部)", Required: false}, + {Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false}, + }, + Tips: []string{ + `dws minutes +detail --id `, + `dws minutes +detail --id --artifacts summary,todos`, + `dws minutes +detail --id --direction 1`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := rt.RequireAll("id"); err != nil { + return err + } + // Reject unknown artifact names early so the user gets a clear message + // rather than a silently empty bundle. + for _, a := range rt.StrSlice("artifacts") { + if _, ok := minutesArtifactTools[strings.ToLower(strings.TrimSpace(a))]; !ok { + return apperrors.NewValidation(fmt.Sprintf("未知的 --artifacts 取值 %q,可选: basic,summary,keywords,transcript,todos", a)) + } + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + taskUUID := rt.Str("id") + direction := rt.Str("direction") + if direction == "" { + direction = "0" + } + + // Resolve which artifacts to pull; default to all in a stable order. + want := rt.StrSlice("artifacts") + if len(want) == 0 { + want = minutesArtifactOrder + } + + bundle := map[string]any{"taskUuid": taskUUID} + for _, raw := range want { + name := strings.ToLower(strings.TrimSpace(raw)) + tool, ok := minutesArtifactTools[name] + if !ok { + continue // guarded by Validate, but stay defensive + } + params := map[string]any{"taskUuid": taskUUID} + if name == "transcript" { + params["direction"] = direction + } + data, err := rt.CallMCPData("minutes", tool, params) + if err != nil { + // Partial-failure tolerance: record and continue. + bundle[name] = map[string]any{"error": err.Error()} + continue + } + bundle[name] = data + } + + return rt.Output(bundle) + }, +} + +// minutesArtifactTools maps the user-facing artifact name to the real MCP tool +// (ground truth: internal/helpers/minutes.go). Each tool takes a single +// "taskUuid"; transcript additionally takes "direction". +var minutesArtifactTools = map[string]string{ + "basic": "get_minutes_basic_info", + "summary": "get_minutes_ai_summary", + "keywords": "get_minutes_keywords", + "transcript": "get_minutes_transcription", + "todos": "list_minutes_todos", +} + +// minutesArtifactOrder is the stable default fan-out order (map iteration is +// unordered, so the "all" case uses this slice). +var minutesArtifactOrder = []string{"basic", "summary", "keywords", "transcript", "todos"} + +func init() { + shortcut.Register(MinutesDetail) +} diff --git a/internal/shortcut/smart/minutes_search.go b/internal/shortcut/smart/minutes_search.go new file mode 100644 index 00000000..ca89d34f --- /dev/null +++ b/internal/shortcut/smart/minutes_search.go @@ -0,0 +1,108 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MinutesSearch: search MY minutes (听记) by keyword and print a projected list. +// +// Steps: +// 1. list my minutes via list_by_keyword_and_time_range with +// belongingConditionId="created", maxResults=20 and keyword=--query, +// mirroring helpers.callListByKeywordRange; +// 2. project each entry to {title, createTime, taskUuid} — field parsing is +// defensive (multiple candidate keys) — and print via rt.Output so it +// honours --format/--jq/--fields; +// 3. if nothing matched, report "没搜到妙记" instead of an empty raw dump. +// +// Read-only: it only lists and reshapes, never mutates any minute. +// +// dws minutes +minutes-search --query 周会 +var MinutesSearch = shortcut.Shortcut{ + Service: "minutes", + Command: "+minutes-search", + Product: "minutes", + Description: "按关键词搜索我的妙记并投影列表", + Intent: "当你想按关键词快速找回自己创建的会议听记(妙记),只需要看到匹配到的标题、创建时间和 taskUuid 列表、而不想拿到一大坨原始字段时使用;" + + "内部按 --query 关键词列出你创建的听记(最多 20 条),再在本地投影出每条的标题、创建时间和 taskUuid。" + + "这是纯只读操作,只做搜索与本地投影,不会修改任何听记;若没有匹配的听记则提示「没搜到妙记」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "按关键词搜索听记(必填)", Required: true}, + }, + Tips: []string{ + `dws minutes +minutes-search --query 周会`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — search my minutes. belongingConditionId / maxResults / keyword + // mirror helpers.callListByKeywordRange. + data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{ + "belongingConditionId": "created", + "maxResults": float64(20), + "keyword": rt.Str("query"), + }) + if err != nil { + return err + } + + // Step 2 — project matched entries. latestMinutesItems (from + // latest_minutes.go) defensively unwraps the list container. + items := latestMinutesItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "title": minutesSearchTitle(m), + "createTime": minutesSearchCreateTime(m), + // latestMinutesUUID probes taskUuid/taskUUID/uuid/id in order. + "taskUuid": latestMinutesUUID(m), + }) + } + + // Step 3 — empty result guard. + if len(results) == 0 { + return apperrors.NewValidation("没搜到妙记") + } + + return rt.Output(map[string]any{"minutes": results}) + }, +} + +// minutesSearchTitle reads a minute's display title, tolerating the common +// title keys the gateway may use. +func minutesSearchTitle(m map[string]any) string { + for _, key := range []string{"title", "minutesTitle", "name", "subject"} { + if s, ok := m[key].(string); ok && s != "" { + return s + } + } + return "" +} + +// minutesSearchCreateTime reads a minute's create time, returning the raw value +// (usually epoch millis) under whichever candidate key is present. +func minutesSearchCreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +func init() { + shortcut.Register(MinutesSearch) +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go new file mode 100644 index 00000000..f47dc6d8 --- /dev/null +++ b/internal/shortcut/smart/my_attendance.go @@ -0,0 +1,129 @@ +// 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 ( + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MyAttendance: show MY punch-in (打卡流水) records for TODAY in one step. +// +// Steps: +// 1. resolve the current logged-in user's userId via the contact server's +// zero-arg get_current_user_profile (no --name needed — it's always "me"); +// 2. compute today's window [00:00 today, 00:00 tomorrow) in the local +// timezone and format both bounds as "yyyy-MM-dd HH:mm:ss" (the format the +// query_check_record helper feeds the tool); +// 3. call query_check_record on the attendance-wukong server with the exact +// nested QueryCheckRecordRequest shape used by +// `dws attendance check record`, then print via rt.Output. +// +// This replaces the manual dance of looking up your own userId, then running +// `dws attendance check record --users --start --end `. +// Read-only; it never modifies any attendance data. +// +// dws attendance +my-attendance +var MyAttendance = shortcut.Shortcut{ + Service: "attendance", + Command: "+my-attendance", + Product: "attendance", + Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", + Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + + "再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," + + "最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{}, + Tips: []string{ + `dws attendance +my-attendance`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the current user's own userId (zero-arg "我的" API). + profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + if err != nil { + return err + } + userID := myAttendanceCurrentUserID(profile) + if userID == "" { + return apperrors.NewValidation( + "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") + } + + // Step 2 — today's window [00:00 today, 00:00 tomorrow) in local time, + // formatted as the tool expects (yyyy-MM-dd HH:mm:ss). + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + end := start.AddDate(0, 0, 1) + const layout = "2006-01-02 15:04:05" + + // Step 3 — query my punch-in records for today. The nested + // QueryCheckRecordRequest shape + field names mirror the + // `dws attendance check record` helper exactly. + data, err := rt.CallMCPData("attendance-wukong", "query_check_record", map[string]any{ + "QueryCheckRecordRequest": map[string]any{ + "userIds": []string{userID}, + "checkDateFrom": start.Format(layout), + "checkDateTo": end.Format(layout), + }, + }) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// myAttendanceCurrentUserID extracts the current user's userId from a +// get_current_user_profile response. The gateway returns one of several shapes, +// so probe them defensively (mirrors helpers.getCurrentUserID): +// - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} +// - {"result": {"userId": ...}} +func myAttendanceCurrentUserID(data map[string]any) string { + if data == nil { + return "" + } + switch result := data["result"].(type) { + case []any: + for _, item := range result { + m, ok := item.(map[string]any) + if !ok { + continue + } + if oem, ok := m["orgEmployeeModel"].(map[string]any); ok { + if uid, ok := oem["userId"].(string); ok && uid != "" { + return uid + } + } + if uid, ok := m["userId"].(string); ok && uid != "" { + return uid + } + } + case map[string]any: + if uid, ok := result["userId"].(string); ok && uid != "" { + return uid + } + if oem, ok := result["orgEmployeeModel"].(map[string]any); ok { + if uid, ok := oem["userId"].(string); ok && uid != "" { + return uid + } + } + } + return "" +} + +func init() { + shortcut.Register(MyAttendance) +} diff --git a/internal/shortcut/smart/my_free.go b/internal/shortcut/smart/my_free.go new file mode 100644 index 00000000..e4017bfd --- /dev/null +++ b/internal/shortcut/smart/my_free.go @@ -0,0 +1,110 @@ +// 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 ( + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MyFree: show MY own busy slots over a range — the self version of +free that +// needs no --who (you rarely want to type your own name). Defaults to today. +// +// Steps: +// +// 1. resolve my own userId via the zero-arg get_current_user_profile (reusing +// myAttendanceCurrentUserID); +// +// 2. query_busy_status for [start,end] (defaults to today 00:00→tomorrow 00:00 +// in local time), then project result[].scheduleItems[] to a flat +// {start,end} busy list (reusing freebusySlots). +// +// dws calendar +my-free +// dws calendar +my-free --start 2026-07-10T09:00:00+08:00 --end 2026-07-10T18:00:00+08:00 +var MyFree = shortcut.Shortcut{ + Service: "calendar", + Command: "+my-free", + Product: "calendar", + Description: "查我自己在某时间段的忙闲(默认今天,无需输入姓名)", + Intent: "当你(或 AI agent)想知道『我自己什么时候有空/忙』、用于安排会议或回复邀约时使用;" + + "不用像 +free 那样传别人的姓名——内部自动解析当前用户的 userId,再查其忙闲时段。" + + "默认查今天(本地时区 00:00 到次日 00:00),也可用 --start/--end 指定 ISO8601 时间范围。" + + "只读操作,只查忙闲、不创建或修改任何日程;返回按时间排列的忙碌时段,空则表示这段时间全空。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间(ISO8601,可选,默认今天 00:00)", Required: false}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间(ISO8601,可选,默认次日 00:00)", Required: false}, + }, + Tips: []string{ + `dws calendar +my-free`, + `dws calendar +my-free --start 2026-07-10T09:00:00+08:00 --end 2026-07-10T18:00:00+08:00`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve my own userId (reuse the +my-attendance parser). + profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + if err != nil { + return err + } + userID := myAttendanceCurrentUserID(profile) + if userID == "" { + return apperrors.NewValidation("无法解析当前用户的 userId") + } + + // Step 2 — resolve the [start,end] window. Both flags are optional and + // default to today's [00:00, next-day 00:00) in local time. + now := time.Now() + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + startMillis := startOfToday.UnixMilli() + endMillis := startOfToday.AddDate(0, 0, 1).UnixMilli() + if rt.Str("start") != "" { + ms, perr := freebusyParseMillis("start", rt.Str("start")) + if perr != nil { + return perr + } + startMillis = ms + } + if rt.Str("end") != "" { + ms, perr := freebusyParseMillis("end", rt.Str("end")) + if perr != nil { + return perr + } + endMillis = ms + } + if endMillis <= startMillis { + return apperrors.NewValidation("--end 必须晚于 --start") + } + + // Step 3 — query and project busy slots (reuse freebusySlots). + data, err := rt.CallMCPData("calendar", "query_busy_status", map[string]any{ + "startTime": startMillis, + "endTime": endMillis, + "userIds": []string{userID}, + }) + if err != nil { + return err + } + busy := freebusySlots(data) + return rt.Output(map[string]any{ + "userId": userID, + "busy": busy, + "free": len(busy) == 0, + }) + }, +} + +func init() { + shortcut.Register(MyFree) +} diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go new file mode 100644 index 00000000..89c18987 --- /dev/null +++ b/internal/shortcut/smart/my_groups.go @@ -0,0 +1,171 @@ +// 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 ( + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MyGroups: list the groups I've joined and project just the key fields +// (会话id / 名称 / 群主 / 人数 / 类型) into a clean, composed payload — instead of +// paging through `chat group list-all` and squinting at the raw MCP response. +// +// Steps: +// 1. list my groups via list_my_groups_pagination (im server); param `limit` +// is copied verbatim from chat.go's `chat group list-all` call site. +// 2. defensively project each group's key fields (field names probed across +// several candidate keys, since the gateway shape isn't guaranteed); +// 3. optionally keep only groups whose type matches --type (Go-side filter — +// the underlying tool has no server-side type parameter). +// +// Read-only: it never modifies any group or membership. +// +// dws chat +my-groups +// dws chat +my-groups --type group +var MyGroups = shortcut.Shortcut{ + Service: "chat", + Command: "+my-groups", + Product: "chat", + Description: "列出我加入的群,可按类型过滤并投影关键字段", + Intent: "当你想快速看一眼自己都加入了哪些群、以及每个群的会话ID、名称、群主和人数,而不想翻分页或盯着原始返回时使用;" + + "内部分页拉取你加入的群列表,把每个群防御式地投影成 会话id / 名称 / 群主 / 人数 / 类型 等关键字段,输出成干净的结果。" + + "可选 --type 在本地按群类型过滤(底层接口本身不带类型参数,故为客户端过滤)。这是只读操作,不会改动任何群或成员关系。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false}, + }, + Tips: []string{ + `dws chat +my-groups`, + `dws chat +my-groups --type group`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination + // call site (chat group list-all); pass a generous page size. + data, err := rt.CallMCPData("im", "list_my_groups_pagination", map[string]any{ + "limit": 200, + }) + if err != nil { + return err + } + + // Step 2 — project key fields defensively. + groups := myGroupsExtract(data) + typeFilter := strings.TrimSpace(rt.Str("type")) + + projected := make([]map[string]any, 0, len(groups)) + for _, g := range groups { + row := myGroupsProject(g) + if typeFilter != "" { + gt, _ := row["type"].(string) + if !strings.EqualFold(strings.TrimSpace(gt), typeFilter) { + continue + } + } + projected = append(projected, row) + } + + return rt.Output(map[string]any{ + "count": len(projected), + "groups": projected, + }) + }, +} + +// myGroupsExtract walks a list_my_groups_pagination response and returns its +// group entries. The gateway wraps the list under one of several common +// container keys, so we probe them (and one nested level) before giving up. +func myGroupsExtract(data map[string]any) []map[string]any { + for _, key := range []string{"result", "list", "groups", "groupList", "items", "data", "records", "conversations"} { + if arr, ok := data[key].([]any); ok { + return myGroupsToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "groups", "groupList", "items", "records", "result", "conversations"} { + if arr, ok := inner[k2].([]any); ok { + return myGroupsToMaps(arr) + } + } + } + } + return nil +} + +func myGroupsToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// myGroupsProject reshapes a single group into the projected key fields, probing +// multiple candidate keys for each because the response shape isn't guaranteed. +func myGroupsProject(m map[string]any) map[string]any { + row := map[string]any{} + if v := myGroupsStr(m, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id"); v != "" { + row["conversationId"] = v + } + if v := myGroupsStr(m, "name", "groupName", "title", "conversationName", "chatName"); v != "" { + row["name"] = v + } + if v := myGroupsStr(m, "ownerUserId", "owner", "ownerId", "ownerOpenDingTalkId", "ownerOpenId", "groupOwnerId"); v != "" { + row["owner"] = v + } + if v, ok := myGroupsInt(m, "memberCount", "memberNum", "memberSize", "userCount", "totalMember", "count"); ok { + row["memberCount"] = v + } + if v := myGroupsStr(m, "groupType", "conversationType", "type", "chatType"); v != "" { + row["type"] = v + } + return row +} + +func myGroupsStr(m map[string]any, keys ...string) string { + for _, key := range keys { + switch v := m[key].(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + return s + } + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + } + } + return "" +} + +func myGroupsInt(m map[string]any, keys ...string) (int64, bool) { + for _, key := range keys { + switch v := m[key].(type) { + case float64: + return int64(v), true + case string: + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + return n, true + } + } + } + return 0, false +} + +func init() { + shortcut.Register(MyGroups) +} diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go new file mode 100644 index 00000000..0ab25b64 --- /dev/null +++ b/internal/shortcut/smart/my_initiated.go @@ -0,0 +1,216 @@ +// 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 ( + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MyInitiated: list the OA approval instances *I* have initiated (submitted), in +// one step. +// +// Steps: +// +// 1. call get_submitted_instances on the OA server — the exact tool + parameter +// names (pageNumber / pageSize as float64, optional query) used by +// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page +// and limit default to the same first-page values (page 1, limit 20). +// +// 2. defensively locate the instance list inside the response (probing common +// container keys, incl. a nested result/data object) and project each item +// down to {title, businessId, status, processInstanceId} with multiple +// candidate keys per field. When no recognisable list is found we print the +// raw payload so nothing is silently lost. +// +// 3. print via rt.Output so it honours --format / --jq / --fields. +// +// Read-only: it only lists and reshapes my submitted approvals, it never +// approves, rejects, revokes or mutates anything. +// +// dws oa +my-initiated +// dws oa +my-initiated --query 报销 +// dws oa +my-initiated --page 2 --limit 50 +var MyInitiated = shortcut.Shortcut{ + Service: "oa", + Command: "+my-initiated", + Product: "oa", + Description: "列出我发起(提交)的审批单据", + Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" + + "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," + + "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" + + "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" + + "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false}, + }, + Tips: []string{ + `dws oa +my-initiated`, + `dws oa +my-initiated --query 报销`, + `dws oa +my-initiated --page 2 --limit 50`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my submitted approval instances. pageNumber/pageSize are + // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd. + page := rt.Int("page") + if page <= 0 { + page = 1 + } + limit := rt.Int("limit") + if limit <= 0 { + limit = 20 + } + params := map[string]any{ + "pageNumber": float64(page), + "pageSize": float64(limit), + } + if q := strings.TrimSpace(rt.Str("query")); q != "" { + params["query"] = q + } + + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + + // Step 2 — project the instance list; fall back to the raw payload when we + // cannot locate a recognisable list. + items := myInitiatedItems(data) + if len(items) == 0 { + return rt.Output(data) + } + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "title": myInitiatedTitle(m), + "businessId": myInitiatedBusinessID(m), + "status": myInitiatedStatus(m), + "processInstanceId": myInitiatedInstanceID(m), + }) + } + + // Step 3 — print the projected list. + return rt.Output(map[string]any{"initiated": results}) + }, +} + +// myInitiatedItems locates the instance list inside a get_submitted_instances +// response, probing common container keys at the top level and nested under a +// result/data object. Returns nil when no list is found. +func myInitiatedItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return myInitiatedToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} { + if arr, ok := inner[k2].([]any); ok { + return myInitiatedToMaps(arr) + } + } + } + } + return nil +} + +func myInitiatedToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// myInitiatedTitle reads an instance's human-readable title, tolerating the +// common title keys the gateway may use. +func myInitiatedTitle(m map[string]any) any { + for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} { + if v := myInitiatedString(m[key]); v != "" { + return v + } + } + return nil +} + +// myInitiatedBusinessID reads an instance's business / order number, preferring +// the customer-visible business id and falling back to related identifiers. +func myInitiatedBusinessID(m map[string]any) any { + for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} { + if v := myInitiatedString(m[key]); v != "" { + return v + } + } + return nil +} + +// myInitiatedStatus reads an instance's current status/result, tolerating both +// the running status and the final result keys. +func myInitiatedStatus(m map[string]any) any { + for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} { + if v := myInitiatedString(m[key]); v != "" { + return v + } + } + return nil +} + +// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the +// common id keys. +func myInitiatedInstanceID(m map[string]any) any { + for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} { + if v := myInitiatedString(m[key]); v != "" { + return v + } + } + return nil +} + +// myInitiatedString coerces a scalar JSON value to a trimmed string, returning +// "" for nil / non-scalar / empty values. +func myInitiatedString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + // approval ids/numbers may arrive as JSON numbers; render without a + // trailing decimal point when integral. + return strconv.FormatFloat(typed, 'f', -1, 64) + case int64: + return strconv.FormatInt(typed, 10) + case int: + return strconv.Itoa(typed) + case bool: + if typed { + return "true" + } + return "false" + default: + return "" + } +} + +func init() { + shortcut.Register(MyInitiated) +} diff --git a/internal/shortcut/smart/next_event.go b/internal/shortcut/smart/next_event.go new file mode 100644 index 00000000..32d320b5 --- /dev/null +++ b/internal/shortcut/smart/next_event.go @@ -0,0 +1,178 @@ +// 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 ( + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// NextEvent: show the single upcoming calendar event that starts soonest, +// looking at the next 7 days from now. +// +// Steps: list the current user's primary-calendar events over +// [now, now+7d] (list_calendar_events, times in epoch millis, params copied +// verbatim from the helper's `event list` call site) → defensively parse the +// event list → pick the one with the earliest start time that is still in the +// future → print a one-line summary. Replaces eyeballing a full +// `calendar event list` dump just to find "what's next". Read-only. +// +// dws calendar +next-event +var NextEvent = shortcut.Shortcut{ + Service: "calendar", + Command: "+next-event", + Product: "calendar", + Description: "查看接下来最近的一个日程(默认扫描未来 7 天)", + Intent: "当你只想知道『我下一个日程是什么、什么时候开始』、而不想翻一整份日程列表时使用;" + + "内部以当前时间为起点、往后 7 天为范围,拉取主日历下的日程," + + "按开始时间升序挑出最近的那一个并打印摘要(标题、开始/结束时间、地点)。" + + "若这 7 天内没有任何日程,会明确提示『近 7 天无日程』。只读,不做任何修改。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{}, + Tips: []string{`dws calendar +next-event`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Window: [now, now+7d]. list_calendar_events expects epoch millis for + // startTime/endTime and calendarId (all copied from the helper's + // `event list` call site). + now := time.Now() + params := map[string]any{ + "calendarId": "primary", + "startTime": now.UnixMilli(), + "endTime": now.Add(7 * 24 * time.Hour).UnixMilli(), + } + + data, err := rt.CallMCPData("calendar", "list_calendar_events", params) + if err != nil { + return err + } + + event := shortcutNextEventPick(data, now) + if event == nil { + return rt.Output(map[string]any{"event": nil, "message": "近 7 天无日程"}) + } + + // Emit a structured event via rt.Output so it honours + // --format/--jq/--fields (previously it printed a fixed text line and + // ignored the output flags, unlike +today/+week). + return rt.Output(map[string]any{"event": shortcutNextEventProject(event)}) + }, +} + +// shortcutNextEventPick walks a list_calendar_events response, keeps only valid +// events (must carry an id) whose start time is at or after `now`, and returns +// the one that starts soonest. Field access is fully defensive because the +// response envelope and per-event shape are not guaranteed. +func shortcutNextEventPick(data map[string]any, now time.Time) map[string]any { + var best map[string]any + var bestStart time.Time + for _, e := range shortcutNextEventList(data) { + id, _ := e["id"].(string) + if strings.TrimSpace(id) == "" { + continue + } + start, ok := shortcutNextEventStart(e) + if !ok { + continue + } + if start.Before(now) { + continue + } + if best == nil || start.Before(bestStart) { + best = e + bestStart = start + } + } + return best +} + +// shortcutNextEventList flattens the events slice out of the common response +// shapes ({result:{events:[...]}}, {events:[...]}, or a bare [...] under +// result/data). +func shortcutNextEventList(data map[string]any) []map[string]any { + if data == nil { + return nil + } + var raw []any + if result, ok := data["result"].(map[string]any); ok { + if ev, ok := result["events"].([]any); ok { + raw = ev + } + } + if raw == nil { + if ev, ok := data["events"].([]any); ok { + raw = ev + } + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutNextEventStart pulls an event's start time as a time.Time, tolerating +// either the nested {start:{dateTime: RFC3339}} shape or a flat string field. +func shortcutNextEventStart(event map[string]any) (time.Time, bool) { + if start, ok := event["start"].(map[string]any); ok { + if dt, ok := start["dateTime"].(string); ok && dt != "" { + if t, err := time.Parse(time.RFC3339, dt); err == nil { + return t, true + } + } + } + for _, k := range []string{"startTime", "startDateTime"} { + if dt, ok := event[k].(string); ok && dt != "" { + if t, err := time.Parse(time.RFC3339, dt); err == nil { + return t, true + } + } + } + return time.Time{}, false +} + +// shortcutNextEventProject reshapes the chosen event into a structured +// {title,start,end,location,eventId} map, degrading gracefully when optional +// fields are missing. Mirrors the projection used by +today / +week. +func shortcutNextEventProject(event map[string]any) map[string]any { + title, _ := event["summary"].(string) + if strings.TrimSpace(title) == "" { + title = "(无标题)" + } + item := map[string]any{"title": title} + if id, ok := event["id"].(string); ok && strings.TrimSpace(id) != "" { + item["eventId"] = id + } + if start, ok := shortcutNextEventStart(event); ok { + item["start"] = start.Format("2006-01-02 15:04") + } + if end, ok := event["end"].(map[string]any); ok { + if dt, ok := end["dateTime"].(string); ok && dt != "" { + if t, err := time.Parse(time.RFC3339, dt); err == nil { + item["end"] = t.Format("2006-01-02 15:04") + } + } + } + if loc, ok := event["location"].(string); ok && strings.TrimSpace(loc) != "" { + item["location"] = loc + } + return item +} + +func init() { + shortcut.Register(NextEvent) +} diff --git a/internal/shortcut/smart/org.go b/internal/shortcut/smart/org.go new file mode 100644 index 00000000..88e61c2d --- /dev/null +++ b/internal/shortcut/smart/org.go @@ -0,0 +1,155 @@ +// 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" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Org: look up the department a person belongs to, by NAME, in one command. +// +// Steps: resolve the person's name → userId → fetch their org detail +// (get_user_info_by_user_ids) → parse the primary deptId out of +// orgEmployeeModel.depts → print that department's detail +// (get_dept_info_by_dept_id). Replaces the manual dance of +// `contact user search` → copy userId → `contact user get --ids ` → +// copy deptId → `contact dept get-info --dept `. +// +// dws contact +org --name 张三 +var Org = shortcut.Shortcut{ + Service: "contact", + Command: "+org", + Product: "contact", + Description: "按姓名查某人所在部门的详情(自动解析 userId 与 deptId)", + Intent: "当你只知道某位同事的姓名、想知道 TA 所在部门(部门ID、名称、人数)时使用;" + + "内部先按姓名解析出唯一 userId,再取 TA 的组织信息拿到主部门 deptId," + + "最后打印该部门的详情。只读,不做任何修改。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "同事姓名/花名", Required: true}, + }, + Tips: []string{`dws contact +org --name 张三`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the person's name to a unique userId. + user, err := resolveUser(rt, rt.Str("name")) + if err != nil { + return err + } + + // Step 2 — fetch that user's org-management detail; it carries the + // department list under orgEmployeeModel.depts. + data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{user.userID}, + }) + if err != nil { + return err + } + + // Step 3 — defensively pull the primary deptId out of the response, + // then print that department's detail. + deptID, ok := shortcutOrgExtractDeptID(data) + if !ok { + return apperrors.NewValidation(fmt.Sprintf( + "没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+ + "TA 可能没有归属部门,或返回结构与预期不符。", + user.name, user.userID)) + } + return rt.CallMCP("get_dept_info_by_dept_id", map[string]any{ + "deptId": deptID, + }) + }, +} + +// shortcutOrgExtractDeptID walks the get_user_info_by_user_ids response and +// returns the first department id it can find, converted to int64 (the type +// get_dept_info_by_dept_id expects). The real-machine payload nests the user +// object differently depending on the transport wrapper, so try several +// candidate shapes before giving up. +func shortcutOrgExtractDeptID(data map[string]any) (int64, bool) { + for _, user := range shortcutOrgCandidateUsers(data) { + model, ok := user["orgEmployeeModel"].(map[string]any) + if !ok { + continue + } + depts, ok := model["depts"].([]any) + if !ok { + continue + } + for _, d := range depts { + dm, ok := d.(map[string]any) + if !ok { + continue + } + if id, ok := shortcutOrgToInt64(dm["deptId"]); ok { + return id, true + } + } + } + return 0, false +} + +// shortcutOrgCandidateUsers flattens the several shapes the user-detail payload +// may take (top-level object, {"result": [...]}, {"data": ...}) into a list of +// user-object maps to probe. +func shortcutOrgCandidateUsers(data map[string]any) []map[string]any { + var out []map[string]any + if data == nil { + return out + } + // The object itself may already be the user record. + out = append(out, data) + for _, key := range []string{"result", "data", "userList", "users"} { + switch v := data[key].(type) { + case map[string]any: + out = append(out, v) + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + } + } + return out +} + +// shortcutOrgToInt64 coerces a JSON-decoded numeric/string deptId to int64. +func shortcutOrgToInt64(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(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func init() { + shortcut.Register(Org) +} diff --git a/internal/shortcut/smart/overdue.go b/internal/shortcut/smart/overdue.go new file mode 100644 index 00000000..f3e3228c --- /dev/null +++ b/internal/shortcut/smart/overdue.go @@ -0,0 +1,165 @@ +// 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 ( + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Overdue: list MY overdue-and-unfinished todos in one step. +// +// Steps: +// +// 1. list my todos via get_user_todos_in_current_org (pageNum / pageSize as +// strings, roleTypes=["executor"], mirroring helpers.todo list); +// +// 2. in Go, keep only the cards whose dueTime exists and is strictly before +// time.Now().UnixMilli() AND that are not yet done (isDone not true and +// finalStatusStage not a completed marker) — field parsing is defensive; +// +// 3. project each surviving card to {subject, dueTime, taskId} and print the +// list via rt.Output so it honours --format/--jq/--fields. +// +// Read-only: it never mutates any todo, it only lists and filters locally. +// +// dws todo +overdue +var Overdue = shortcut.Shortcut{ + Service: "todo", + Command: "+overdue", + Product: "todo", + Description: "列出我已过期未完成的待办", + Intent: "当你想快速看清自己有哪些待办已经过了截止时间却还没做完、方便优先处理时使用;" + + "内部先拉取你当前组织下作为执行人(executor)的待办列表,再在本地按「有截止时间(dueTime) 且早于当前时刻 且尚未完成」的条件筛选," + + "最后只打印这些逾期待办的标题(subject)、截止时间(dueTime) 和任务 ID(taskId)。" + + "这是纯只读操作,只做列表与本地过滤,不会修改或完成任何待办;若没有逾期待办则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{}, + Tips: []string{ + `dws todo +overdue`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my todos. pageNum/pageSize are strings and roleTypes + // mirror helpers.buildListTodoTaskArgs (default executor). + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{ + "pageNum": "1", + "pageSize": "50", + "roleTypes": []string{"executor"}, + }) + if err != nil { + return err + } + + // Step 2 — filter locally: dueTime present, in the past, not done. + now := time.Now().UnixMilli() + cards := shortcutTodoCards(data) // reused from todo_done.go + overdue := make([]map[string]any, 0, len(cards)) + for _, m := range cards { + due, ok := shortcutOverdueDueTime(m) + if !ok || due >= now { + continue + } + if shortcutOverdueIsDone(m) { + continue + } + taskID := shortcutTodoTaskID(m) // reused from todo_done.go + subject, _ := m["subject"].(string) + overdue = append(overdue, map[string]any{ + "subject": subject, + "dueTime": due, + "taskId": taskID, + }) + } + + // Step 3 — print the projected overdue list. + return rt.Output(map[string]any{"overdue": overdue}) + }, +} + +// shortcutOverdueDueTime reads a card's dueTime as epoch millis, tolerating both +// numeric (float64) and string JSON encodings. Returns ok=false when the field +// is absent, zero, or unparseable. +func shortcutOverdueDueTime(m map[string]any) (int64, bool) { + for _, key := range []string{"dueTime", "planFinishDate", "gmtDue"} { + switch v := m[key].(type) { + case float64: + if v > 0 { + return int64(v), true + } + case int64: + if v > 0 { + return v, true + } + case string: + s := strings.TrimSpace(v) + if s == "" { + continue + } + // dueTime is normally epoch millis; parse leniently. + var n int64 + var neg bool + ok := len(s) > 0 + for i, c := range s { + if i == 0 && c == '-' { + neg = true + continue + } + if c < '0' || c > '9' { + ok = false + break + } + n = n*10 + int64(c-'0') + } + if ok && n > 0 && !neg { + return n, true + } + } + } + return 0, false +} + +// shortcutOverdueIsDone reports whether a card is already completed, probing the +// common completion markers defensively: a boolean isDone, or a +// finalStatusStage/statusStage string that reads as finished/done. +func shortcutOverdueIsDone(m map[string]any) bool { + switch v := m["isDone"].(type) { + case bool: + if v { + return true + } + case string: + if strings.EqualFold(strings.TrimSpace(v), "true") { + return true + } + } + if v, ok := m["done"].(bool); ok && v { + return true + } + for _, key := range []string{"finalStatusStage", "statusStage", "status"} { + if s, ok := m[key].(string); ok { + s = strings.ToUpper(strings.TrimSpace(s)) + switch s { + case "DONE", "FINISHED", "COMPLETED", "COMPLETE": + return true + } + } + } + return false +} + +func init() { + shortcut.Register(Overdue) +} diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go new file mode 100644 index 00000000..3c4e34bd --- /dev/null +++ b/internal/shortcut/smart/pending_approvals.go @@ -0,0 +1,128 @@ +// 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 ( + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to +// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval), +// this shortcut only looks — it never approves / rejects / mutates anything. +// +// Steps (tool name + param keys copied verbatim from helpers/oa.go): +// +// 1. list my pending approvals via list_pending_approvals with starTime / +// endTime as float64 milliseconds over a recent (~90 day) window, mirroring +// `dws oa approval list-pending`; optional --limit maps to pageSize (float64). +// +// 2. defensively unwrap the returned instance list (multiple candidate +// container keys, one nested level) and project each entry to a readable +// shape {title, originatorName, processInstanceId, createTime} — every field +// probed across several candidate keys. +// +// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty +// raw dump. +// +// dws oa +pending +// dws oa +pending --limit 10 +var PendingApprovals = shortcut.Shortcut{ + Service: "oa", + Command: "+pending", + Product: "oa", + Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" + + "而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" + + "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" + + "若当前没有待你审批的任务则提示「当前没有待我审批的任务」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, + }, + Tips: []string{ + `dws oa +pending`, + `dws oa +pending --limit 10`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my pending approvals. starTime/endTime are float64 + // milliseconds; pageSize is optional. Keys copied verbatim from + // helpers.list-pending (list_pending_approvals). Window: last ~90 days. + now := time.Now() + params := map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), + "endTime": float64(now.UnixMilli()), + } + if rt.Changed("limit") { + if n := rt.Int("limit"); n > 0 { + params["pageSize"] = float64(n) + } + } + data, err := rt.CallMCPData("oa", "list_pending_approvals", params) + if err != nil { + return err + } + + // Step 2 — project entries. shortcutApproveItems (approve.go) defensively + // unwraps the list container (result/list/instances/items/data/records/ + // processList, incl. one nested level). + items := shortcutApproveItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + // shortcutApproveTitle probes title/processTitle/name/subject/… + "title": shortcutApproveTitle(m), + // shortcutApproveInstanceID probes processInstanceId/instanceId/… + "processInstanceId": shortcutApproveInstanceID(m), + "originatorName": pendingApprovalsOriginator(m), + "createTime": pendingApprovalsCreateTime(m), + }) + } + + // Step 3 — empty result guard. + if len(results) == 0 { + return apperrors.NewValidation("当前没有待我审批的任务") + } + + return rt.Output(map[string]any{"count": len(results), "pending": results}) + }, +} + +// pendingApprovalsOriginator reads the initiator's display name, tolerating the +// common field names the gateway may use. +func pendingApprovalsOriginator(m map[string]any) string { + for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} { + if s := shortcutApproveStr(m[key]); s != "" { + return s + } + } + return "" +} + +// pendingApprovalsCreateTime reads a pending approval's create time, returning +// the raw value (usually epoch millis) under whichever candidate key is present. +func pendingApprovalsCreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +func init() { + shortcut.Register(PendingApprovals) +} diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go new file mode 100644 index 00000000..b255d973 --- /dev/null +++ b/internal/shortcut/smart/recent_mail.go @@ -0,0 +1,188 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// RecentMail: list recent inbox mail threads (conversations) and project a +// compact list in one step. +// +// Steps: +// +// 1. resolve the mailbox address — use --email when given, otherwise pick the +// current user's first bound mailbox via list_user_mailboxes (reusing +// searchMailFirstMailbox from search_mail.go); +// +// 2. resolve the folder ID — list_mailbox_threads requires a folderId (a folder +// ID, not a name). Use --folder when given, otherwise resolve the inbox by +// listing the mailbox's top-level folders via list_folders and matching the +// inbox displayName (收件箱 / Inbox); +// +// 3. list the folder's threads via list_mailbox_threads (email / folderId / size +// mirror the helpers.mail thread-list call; size defaults to 20, capped at +// 100, matching the helper's 1..100 limit); +// +// 4. in Go, project each conversation to {subject, from, date, threadId} and +// print the list via rt.Output so it honours --format/--jq/--fields. +// +// Read-only: it only lists and projects, never mutating any mail. +// +// dws mail +recent-mail +// dws mail +recent-mail --limit 30 +// dws mail +recent-mail --email user@company.com --folder 2 +var RecentMail = shortcut.Shortcut{ + Service: "mail", + Command: "+recent-mail", + Product: "mail", + Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", + Intent: "当你想快速看一眼自己邮箱里近期的邮件会话(收件箱线程/conversation),只需要一份精简清单(主题、发件人、最后修改时间、会话 threadId)、" + + "而不想翻完整正文或原始字段时使用;" + + "内部先确定要看的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再解析要看的文件夹——" + + "你可以用 --folder 指定文件夹 ID,不指定时自动定位收件箱——然后列出该文件夹下的近期会话," + + "最后在本地把每条会话投影成 {subject, from, date, threadId} 打印出来,可配合 --format/--jq/--fields。" + + "这是纯只读操作,只做列举与本地投影,不会修改、发送或删除任何邮件;若最近没有邮件则提示「最近没有邮件」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, + {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, + {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, + }, + Tips: []string{ + `dws mail +recent-mail`, + `dws mail +recent-mail --limit 30`, + `dws mail +recent-mail --email user@company.com --folder 2`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the mailbox address (reuses search_mail.go). + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + + // Step 2 — resolve the folder ID. list_mailbox_threads requires folderId. + folderID := rt.Str("folder") + if folderID == "" { + resolved, err := recentMailInboxFolder(rt, email) + if err != nil { + return err + } + folderID = resolved + } + + // Step 3 — list threads. email/folderId/size mirror the helpers.mail + // thread-list call; size is an int in 1..100. + size := rt.Int("limit") + if size <= 0 { + size = 20 + } + if size > 100 { + size = 100 + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{ + "email": email, + "folderId": folderID, + "size": size, + }) + if err != nil { + return err + } + + // Step 4 — project each conversation to a compact record. + threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records") + results := make([]map[string]any, 0, len(threads)) + for _, t := range threads { + results = append(results, map[string]any{ + "subject": searchMailFirstString(t, "subject", "title", "topic"), + "from": recentMailSenders(t), + "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), + "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), + }) + } + + // Empty result guard. + if len(results) == 0 { + return apperrors.NewValidation("最近没有邮件") + } + + return rt.Output(map[string]any{"count": len(results), "mails": results}) + }, +} + +// recentMailInboxFolder lists the mailbox's top-level folders via list_folders +// and returns the inbox folder ID, matching the inbox displayName defensively. +// The helper documents the list under "folders" with id / displayName fields. +func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, error) { + data, err := rt.CallMCPData("mail", "list_folders", map[string]any{ + "email": email, + }) + if err != nil { + return "", err + } + folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records") + inboxNames := map[string]bool{ + "收件箱": true, "inbox": true, + } + for _, f := range folders { + name := searchMailFirstString(f, "displayName", "name", "folderName") + if inboxNames[name] { + if id := searchMailFirstString(f, "id", "folderId"); id != "" { + return id, nil + } + } + } + return "", apperrors.NewValidation("未找到收件箱文件夹,请用 --folder 指定文件夹 ID(可通过 dws mail folder list 获取)") +} + +// recentMailSenders reads a conversation's sender list. The helper documents a +// "senders" array of {email, name}; we tolerate a plain string, a single object, +// and fall back to the shared searchMailFrom logic. +func recentMailSenders(t map[string]any) any { + if arr, ok := t["senders"].([]any); ok && len(arr) > 0 { + names := make([]string, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]any) + if !ok { + if s, ok := it.(string); ok && s != "" { + names = append(names, s) + } + continue + } + name := searchMailFirstString(m, "name", "displayName") + addr := searchMailFirstString(m, "email", "emailAddress", "address") + switch { + case name != "" && addr != "": + names = append(names, name+" <"+addr+">") + case addr != "": + names = append(names, addr) + case name != "": + names = append(names, name) + } + } + if len(names) > 0 { + return names + } + } + return searchMailFrom(t) +} + +func init() { + shortcut.Register(RecentMail) +} diff --git a/internal/shortcut/smart/record_share_links.go b/internal/shortcut/smart/record_share_links.go new file mode 100644 index 00000000..ac15a865 --- /dev/null +++ b/internal/shortcut/smart/record_share_links.go @@ -0,0 +1,187 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// RecordShareLinks: get share links for MANY aitable (多维表) records in one +// command, transparently working around the tool's per-call cap. +// +// The atomic tool get_record_share_url accepts a recordIds array but caps a +// single call at 20 (see the +record-share-url 1:1 shortcut and +// internal/helpers/aitable.go). To share more than 20 records a user has to +// split the list by hand and stitch the results. This shortcut dedups the +// requested recordIds (preserving order), chunks them into batches of ≤20, fans +// each batch out to get_record_share_url and merges every returned +// {recordId, shareUrl} into one projected list — a failing batch is recorded and +// does not abort the rest. +// +// Cross-server note: get_record_share_url runs on the "aitable-helper" MCP +// server (not "aitable"), so the calls go through CallMCPData with an explicit +// product, mirroring helpers.callAitableHelperTool. +// +// dws aitable +record-share-links --base B --table T --record-ids rec1,rec2,…,rec50 +// dws aitable +record-share-links --base B --table T --record-ids rec1 --view-id viw_VIP +var RecordShareLinks = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-share-links", + Product: "aitable", + Description: "批量(可 >20 条)获取多维表记录分享链接:去重+分片+合并", + Intent: "当你要一次性拿到很多条多维表记录的分享链接、数量可能超过底层工具单次 20 条上限时使用;" + + "内部先对 --record-ids 去重(保持顺序),再按每批 ≤20 条切片,逐批调用 get_record_share_url(在 aitable-helper 服务上)," + + "最后把各批返回的 {recordId, shareUrl} 合并成一个列表;某一批失败会记录错误但不影响其余批。" + + "这是只读操作,只生成/获取分享链接、不修改记录。可选 --view-id 生成带视图上下文的链接。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(记录所属 base)", Required: true}, + {Name: "table", Type: shortcut.FlagString, Desc: "Table ID(记录所属数据表)", Required: true}, + {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "记录 ID 列表,可 >20(自动去重+分片,必填)", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "视图 ID:生成带视图上下文的链接(可选)", Required: false}, + }, + Tips: []string{ + `dws aitable +record-share-links --base B --table T --record-ids rec1,rec2,rec3`, + `dws aitable +record-share-links --base B --table T --record-ids rec1 --view-id viw_VIP`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("base", "table", "record-ids") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Dedup while preserving first-seen order. + ids := dedupStrings(rt.StrSlice("record-ids")) + if len(ids) == 0 { + return apperrors.NewValidation("--record-ids 去重后为空") + } + + baseID := rt.Str("base") + tableID := rt.Str("table") + viewID := rt.Str("view-id") + + items := make([]map[string]any, 0, len(ids)) + var batchErrors []map[string]any + for start := 0; start < len(ids); start += recordShareBatchSize { + end := start + recordShareBatchSize + if end > len(ids) { + end = len(ids) + } + chunk := ids[start:end] + params := map[string]any{ + "baseId": baseID, + "tableId": tableID, + "recordIds": chunk, + } + if viewID != "" { + params["viewId"] = viewID + } + // get_record_share_url lives on the aitable-helper server. + data, err := rt.CallMCPData("aitable-helper", "get_record_share_url", params) + if err != nil { + batchErrors = append(batchErrors, map[string]any{ + "recordIds": chunk, + "error": err.Error(), + }) + continue + } + items = append(items, recordShareItems(data)...) + } + + out := map[string]any{ + "base": baseID, + "table": tableID, + "total": len(ids), + "batches": (len(ids) + recordShareBatchSize - 1) / recordShareBatchSize, + "items": items, + } + if len(batchErrors) > 0 { + out["errors"] = batchErrors + } + return rt.Output(out) + }, +} + +// recordShareBatchSize is the per-call cap enforced by get_record_share_url. +const recordShareBatchSize = 20 + +// dedupStrings removes duplicate and blank entries, preserving first-seen order. +func dedupStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// recordShareItems defensively unwraps the {recordId, shareUrl} list from a +// get_record_share_url response. Per helpers the payload is data.items, but the +// gateway may also flatten it to a top-level items — probe both. +func recordShareItems(data map[string]any) []map[string]any { + containers := []any{data["items"]} + if d, ok := data["data"].(map[string]any); ok { + containers = append(containers, d["items"]) + } + for _, c := range containers { + list, ok := c.([]any) + if !ok { + continue + } + out := make([]map[string]any, 0, len(list)) + for _, e := range list { + if m, ok := e.(map[string]any); ok { + out = append(out, map[string]any{ + "recordId": firstNonEmpty(m, "recordId", "record_id", "id"), + "shareUrl": firstAny(m, "shareUrl", "share_url", "url"), + }) + } + } + if len(out) > 0 { + return out + } + } + return nil +} + +// firstNonEmpty returns the first non-empty string value among the candidate keys. +func firstNonEmpty(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} + +// firstAny returns the first present (possibly null) value among the candidate +// keys — shareUrl is intentionally kept even when null to signal a failed row. +func firstAny(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +func init() { + shortcut.Register(RecordShareLinks) +} diff --git a/internal/shortcut/smart/related_tasks.go b/internal/shortcut/smart/related_tasks.go new file mode 100644 index 00000000..8248ae55 --- /dev/null +++ b/internal/shortcut/smart/related_tasks.go @@ -0,0 +1,208 @@ +// 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" +) + +// RelatedTasks: list ALL todos related to me in one step — the union of the +// todos where I am the creator, the executor, or a participant — mirroring +// the creator+executor+participant roles-union view. +// +// Steps: +// +// 1. list my todos via get_user_todos_in_current_org (pageNum / pageSize as +// strings, mirroring helpers.todo list) with roleTypes defaulting to all +// three enum values ["creator","executor","participant"], so the server +// returns todos where I hold ANY of those roles. creator / executor / +// participant are exactly the values accepted by helpers.parseRoleTypes. +// --role-types (CSV) can override the default; --status is passed through +// as todoStatus, both mirroring helpers.buildListTodoTaskArgs; +// +// 2. dedupe the returned cards by taskId (one todo can appear more than once +// when I hold several roles on it), then project each surviving card to a +// clean {title, status, priority, creator, planFinishDate, taskId} shape +// with defensive multi-key field probing; +// +// 3. print the deduped list via rt.Output so it honours --format/--jq/--fields. +// +// Read-only: it only lists, dedupes and projects, it never mutates any todo. +// +// dws todo +related-tasks +var RelatedTasks = shortcut.Shortcut{ + Service: "todo", + Command: "+related-tasks", + Product: "todo", + Description: "一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重)", + Intent: "当你想一次看清『所有和我有关的待办』——不管是我创建(creator)的、指派给我执行(executor)的、还是我作为参与人(participant)协作的——时使用;" + + "内部默认拉取你当前组织下 roleTypes=[\"creator\",\"executor\",\"participant\"] 三种角色的待办并集(这三个值正是待办列表支持的角色枚举)," + + "再在本地按任务 ID(taskId) 去重(同一条待办可能因多角色重复出现),把每条投影成标题、状态、优先级、创建人、计划完成时间和 taskId 打印出来。" + + "可用 --role-types 以逗号分隔覆盖默认角色(取值 creator/executor/participant),可用 --status 透传 todoStatus 过滤状态。" + + "这是纯只读操作,只做列表、去重与投影,不会创建或修改任何待办;若没有与你相关的待办则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + { + Name: "role-types", + Type: shortcut.FlagString, + Desc: "覆盖默认角色范围,逗号分隔,取值 creator/executor/participant;不传则默认三者并集", + }, + { + Name: "status", + Type: shortcut.FlagString, + Desc: "按 todoStatus 过滤(透传给 get_user_todos_in_current_org)", + }, + }, + Tips: []string{ + `dws todo +related-tasks`, + `dws todo +related-tasks --role-types creator,executor`, + `dws todo +related-tasks --status TODO`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — build params. roleTypes defaults to the three-role union; + // --role-types overrides it (validated like helpers.parseRoleTypes). + roleTypes := []string{"creator", "executor", "participant"} + if rt.Changed("role-types") { + parsed, err := parseRelatedRoleTypes(rt.Str("role-types")) + if err != nil { + return err + } + if len(parsed) > 0 { + roleTypes = parsed + } + } + + params := map[string]any{ + "pageNum": "1", + "pageSize": "50", + "roleTypes": roleTypes, + } + if rt.Changed("status") { + if v := strings.TrimSpace(rt.Str("status")); v != "" { + params["todoStatus"] = v + } + } + + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", params) + if err != nil { + return err + } + + // Step 2 — dedupe by taskId and project. + cards := shortcutTodoCards(data) + seen := make(map[string]bool, len(cards)) + results := make([]map[string]any, 0, len(cards)) + for _, m := range cards { + taskID := shortcutRelatedTaskID(m) + if taskID != "" { + if seen[taskID] { + continue + } + seen[taskID] = true + } + results = append(results, shortcutRelatedProject(m, taskID)) + } + + if len(results) == 0 { + return apperrors.NewValidation("没有与你相关的待办(creator/executor/participant 三种角色下均为空)") + } + + // Step 3 — print the deduped, projected list. + return rt.Output(map[string]any{"tasks": results, "count": len(results)}) + }, +} + +// parseRelatedRoleTypes parses a comma-separated role-types string and validates +// each value, mirroring helpers.parseRoleTypes. Only creator / executor / +// participant are allowed. +func parseRelatedRoleTypes(s string) ([]string, error) { + allowed := map[string]bool{"creator": true, "executor": true, "participant": true} + out := make([]string, 0, 3) + for _, part := range strings.Split(s, ",") { + p := strings.TrimSpace(part) + if p == "" { + continue + } + if !allowed[p] { + return nil, apperrors.NewValidation(fmt.Sprintf("非法的 role-type %q,合法取值:creator, executor, participant", p)) + } + out = append(out, p) + } + return out, nil +} + +// shortcutRelatedTaskID reads a card's task id, probing common aliases and +// tolerating both string and numeric JSON encodings. +func shortcutRelatedTaskID(m map[string]any) string { + for _, key := range []string{"taskId", "todoId", "id"} { + switch v := m[key].(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + return s + } + case float64: + if v != 0 { + return fmt.Sprintf("%.0f", v) + } + case int64: + if v != 0 { + return fmt.Sprintf("%d", v) + } + } + } + return "" +} + +// shortcutRelatedProject projects a raw todo card to a clean output shape, +// probing multiple candidate keys per field defensively. +func shortcutRelatedProject(m map[string]any, taskID string) map[string]any { + item := map[string]any{"taskId": taskID} + if v := shortcutRelatedStr(m, "subject", "title", "name"); v != "" { + item["title"] = v + } + if v := shortcutRelatedStr(m, "todoStatus", "status", "finalStatusStage", "statusStage"); v != "" { + item["status"] = v + } + if v, ok := m["priority"]; ok { + item["priority"] = v + } + if v := shortcutRelatedStr(m, "creatorName", "creator", "creatorId"); v != "" { + item["creator"] = v + } + if due, ok := shortcutOverdueDueTime(m); ok { + item["planFinishDate"] = due + } + return item +} + +// shortcutRelatedStr returns the first non-empty string value among the given +// candidate keys. +func shortcutRelatedStr(m map[string]any, keys ...string) string { + for _, key := range keys { + if s, ok := m[key].(string); ok { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + } + return "" +} + +func init() { + shortcut.Register(RelatedTasks) +} diff --git a/internal/shortcut/smart/remind.go b/internal/shortcut/smart/remind.go new file mode 100644 index 00000000..96dec855 --- /dev/null +++ b/internal/shortcut/smart/remind.go @@ -0,0 +1,85 @@ +// 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" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Remind: create a personal todo for YOURSELF with an optional due/reminder time, +// in one command. No name resolution and no executorIds — a personal todo without +// executorIds defaults to the current user. +// +// Steps: take --task as the subject, and (optionally) parse --at into epoch +// milliseconds (mirroring the todo helper's parseISOTimeToMillis, which stores +// dueTime as int64 millis) → create_personal_todo. Replaces having to look up +// your own userId before `todo +create`. +// +// dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00 +var Remind = shortcut.Shortcut{ + Service: "todo", + Command: "+remind", + Product: "todo", + Description: "给自己创建一条带截止/提醒时间的待办", + Intent: "当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;" + + "内部直接创建个人待办,不传 executorIds 时默认执行人就是你自己,--at 会按 ISO8601 解析为截止时间。会真实创建待办。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "task", Type: shortcut.FlagString, Desc: "待办标题/内容", Required: true}, + {Name: "at", Type: shortcut.FlagString, Desc: "截止/提醒时间(ISO8601,可选,如 2026-03-10T18:00:00+08:00)"}, + }, + Tips: []string{`dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // subject is the only required field; a personal todo with no executorIds + // is created for the current user. + vo := map[string]any{ + "subject": rt.Str("task"), + } + + // Optional due/reminder time. The todo helper feeds --due through + // parseISOTimeToMillis and stores dueTime as epoch milliseconds (int64), + // so we do the same here rather than passing a raw string. + if rt.Changed("at") { + ms, err := shortcutRemindParseMillis("at", rt.Str("at")) + if err != nil { + return err + } + vo["dueTime"] = ms + } + + return rt.CallMCP("create_personal_todo", map[string]any{ + "PersonalTodoCreateVO": vo, + }) + }, +} + +// shortcutRemindParseMillis parses an ISO8601 timestamp into epoch milliseconds, +// returning a clear validation error naming the offending flag. Mirrors the todo +// helper's parseISOTimeToMillis (which the CLI uses to build dueTime). +func shortcutRemindParseMillis(flag, value string) (int64, error) { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf( + "--%s 时间格式无效:%q,请使用 ISO8601(如 2026-03-10T18:00:00+08:00)", flag, value)) + } + return t.UnixMilli(), nil +} + +func init() { + shortcut.Register(Remind) +} diff --git a/internal/shortcut/smart/replace_batch.go b/internal/shortcut/smart/replace_batch.go new file mode 100644 index 00000000..365985a9 --- /dev/null +++ b/internal/shortcut/smart/replace_batch.go @@ -0,0 +1,135 @@ +// 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" +) + +// ReplaceBatch: apply MANY word replacements to one minute (听记) in a single +// command. +// +// dws's atomic tool replace_minutes_text handles exactly one +// originalText→replacedText pair (see internal/helpers/minutes.go, the +// +word-replace 1:1 shortcut). Fixing several terms at once means calling it +// repeatedly and eyeballing each result. This shortcut takes multiple --pair +// "原文=>替换" entries, validates them (rejecting duplicate source words so two +// rules don't fight over the same term), applies each via replace_minutes_text +// and aggregates a per-pair {applied|error} report through rt.Output — one +// failing pair does not abort the rest. +// +// dws minutes +replace-batch --id --pair "张三=>张三丰" --pair "Q2=>第二季度" +var ReplaceBatch = shortcut.Shortcut{ + Service: "minutes", + Command: "+replace-batch", + Product: "minutes", + Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)", + Intent: "当你要在同一条听记里一次性纠正多个词(如把多个错识别的人名/术语统一替换),而底层工具一次只能替换一组时使用;" + + "内部按多个 --pair \"原文=>替换\" 逐组调用替换工具,先在本地校验去重(同一个「原文」不能出现两次,避免两条规则互相打架)," + + "再逐组应用并聚合每组的成功/失败结果,某一组失败不会中断其余组。这是写操作,会实际修改听记文字内容,请确认 taskUuid 与替换规则无误。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true}, + {Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组(必填)`, Required: true}, + }, + Tips: []string{ + `dws minutes +replace-batch --id --pair "张三=>张三丰"`, + `dws minutes +replace-batch --id --pair "Q2=>第二季度" --pair "PM=>产品经理"`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := rt.RequireAll("id", "pair"); err != nil { + return err + } + _, err := parseReplacePairs(rt.StrSlice("pair")) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + taskUUID := rt.Str("id") + pairs, err := parseReplacePairs(rt.StrSlice("pair")) + if err != nil { + return err // already validated, but stay defensive + } + + results := make([]map[string]any, 0, len(pairs)) + applied := 0 + for _, p := range pairs { + // Params mirror helpers replace_minutes_text call site: taskUuid / + // originalText / replacedText. + _, callErr := rt.CallMCPData("minutes", "replace_minutes_text", map[string]any{ + "taskUuid": taskUUID, + "originalText": p.orig, + "replacedText": p.repl, + }) + entry := map[string]any{"originalText": p.orig, "replacedText": p.repl} + if callErr != nil { + entry["error"] = callErr.Error() + } else { + entry["applied"] = true + applied++ + } + results = append(results, entry) + } + + return rt.Output(map[string]any{ + "taskUuid": taskUUID, + "total": len(pairs), + "applied": applied, + "failed": len(pairs) - applied, + "results": results, + }) + }, +} + +// replacePair is one parsed "原文=>替换" rule. +type replacePair struct { + orig string + repl string +} + +// replacePairSep separates original from replacement text in a --pair value. +// "=>" is chosen because a bare "=" commonly appears inside the source text. +const replacePairSep = "=>" + +// parseReplacePairs parses "原文=>替换" entries and rejects malformed input, +// empty source text, and duplicate source words (which would make two rules +// contend for the same term). +func parseReplacePairs(raw []string) ([]replacePair, error) { + pairs := make([]replacePair, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, r := range raw { + idx := strings.Index(r, replacePairSep) + if idx < 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("替换规则 %q 缺少分隔符 %q,应为 \"原文%s替换\"", r, replacePairSep, replacePairSep)) + } + orig := strings.TrimSpace(r[:idx]) + repl := strings.TrimSpace(r[idx+len(replacePairSep):]) + if orig == "" { + return nil, apperrors.NewValidation(fmt.Sprintf("替换规则 %q 的「原文」不能为空", r)) + } + if _, dup := seen[orig]; dup { + return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", orig)) + } + seen[orig] = struct{}{} + pairs = append(pairs, replacePair{orig: orig, repl: repl}) + } + return pairs, nil +} + +func init() { + shortcut.Register(ReplaceBatch) +} diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go new file mode 100644 index 00000000..c756573a --- /dev/null +++ b/internal/shortcut/smart/report_latest.go @@ -0,0 +1,220 @@ +// 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 ( + "strconv" + "strings" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报) +// in one step (DingTalk-native). +// +// Steps: +// +// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/ +// endTime mirror helpers.runReportSent — the server caps a single query span +// at 20 days, so we default to the last-20-days window just like the helper. +// An optional --keyword is passed through as report_template_name (the same +// filter the helper exposes as --template-name). +// +// 2. locate the newest entry: the item with the largest create time +// (createTime/gmtCreate/sendTime), falling back to the first item that +// carries a reportId (lists come back newest-first). +// +// 3. print that report's body via get_report_entry_details (report_id param +// mirrors helpers.runReportDetail). If nothing carries a reportId we fall +// back to printing the picked list row via rt.Output. +// +// If I have not sent any report in the window it reports "暂无日志" instead of +// failing obscurely. +// +// dws report +report-latest +// dws report +report-latest --keyword 日报 +var ReportLatest = shortcut.Shortcut{ + Service: "report", + Command: "+report-latest", + Product: "report", + Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)", + Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" + + "内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," + + "自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" + + "这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false}, + }, + Tips: []string{ + `dws report +report-latest`, + `dws report +report-latest --keyword 日报`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list the reports I sent. Mirror helpers.runReportSent: the + // server caps a single get_send_report_list query at a 20-day span, so + // default startTime/endTime to the last-20-days window. cursor/size are + // floats and report_template_name is the optional keyword filter. + now := time.Now() + startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli() + endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli() + + listArgs := map[string]any{ + "cursor": float64(0), + "size": float64(20), + "startTime": float64(startMs), + "endTime": float64(endMs), + } + if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" { + listArgs["report_template_name"] = kw + } + + data, err := rt.CallMCPData("report", "get_send_report_list", listArgs) + if err != nil { + return err + } + + // Step 2 — pick the newest sent report. + row, reportID := shortcutReportLatestPick(data) + if row == nil { + return apperrors.NewValidation("暂无日志") + } + + // Step 3 — print its detail (report_id mirrors helpers.runReportDetail). + // If we somehow could not read a reportId, fall back to the list row. + if reportID == "" { + return rt.Output(row) + } + return rt.CallMCP("get_report_entry_details", map[string]any{ + "report_id": reportID, + }) + }, +} + +// shortcutReportLatestPick walks a get_send_report_list response, finds the +// report entries, and returns the newest one (largest create time, falling back +// to the first entry that carries a reportId) together with its reportId. +// Returns (nil, "") when there are no entries. +func shortcutReportLatestPick(data map[string]any) (map[string]any, string) { + items := shortcutReportLatestItems(data) + if len(items) == 0 { + return nil, "" + } + + var firstItem map[string]any + firstID := "" + var bestItem map[string]any + bestID := "" + var bestTime int64 + haveTime := false + + for _, m := range items { + id := shortcutReportLatestID(m) + if id == "" { + continue + } + if firstItem == nil { + firstItem = m + firstID = id + } + if t, ok := shortcutReportLatestCreateMillis(m); ok { + if !haveTime || t > bestTime { + haveTime = true + bestTime = t + bestItem = m + bestID = id + } + } + } + + if haveTime && bestItem != nil { + return bestItem, bestID + } + if firstItem != nil { + return firstItem, firstID + } + // No entry carried a reportId; still surface the first raw item so the + // caller can print something rather than mis-report "暂无日志". + return items[0], "" +} + +// shortcutReportLatestItems pulls the report entries out of a +// get_send_report_list response, probing the same container keys the helper's +// findReportListItems uses (list/items/data/reports/records/report_list/ +// reportList nested under the top level or under result). +func shortcutReportLatestItems(data map[string]any) []map[string]any { + keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return shortcutReportLatestToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} { + if arr, ok := inner[k2].([]any); ok { + return shortcutReportLatestToMaps(arr) + } + } + } + } + return nil +} + +func shortcutReportLatestToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in +// helpers.reportIDFromMap. +func shortcutReportLatestID(m map[string]any) string { + for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} { + if id, ok := m[key].(string); ok { + if id = strings.TrimSpace(id); id != "" { + return id + } + } + } + return "" +} + +// shortcutReportLatestCreateMillis reads an entry's create time in millis, +// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes. +func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) { + for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} { + switch v := m[key].(type) { + case float64: + if v > 0 { + return int64(v), true + } + case string: + if s := strings.TrimSpace(v); s != "" { + if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 { + return n, true + } + } + } + } + return 0, false +} + +func init() { + shortcut.Register(ReportLatest) +} diff --git a/internal/shortcut/smart/reschedule.go b/internal/shortcut/smart/reschedule.go new file mode 100644 index 00000000..c350fd71 --- /dev/null +++ b/internal/shortcut/smart/reschedule.go @@ -0,0 +1,85 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Reschedule: change the time of an EXISTING calendar event in one step, leaving +// every other field (title, description, attendees, rooms, ...) untouched. +// +// Steps: confirm the event exists via get_calendar_detail (so a bad eventId +// fails clearly before any write), then update only its start/end time via +// update_calendar_event. Replaces `calendar event get --id` (verify) → +// `calendar event update --id --start --end` where you must remember not to +// touch anything else. +// +// dws calendar +reschedule --event EVENT_ID \ +// --start "2026-03-10T15:00:00+08:00" --end "2026-03-10T16:00:00+08:00" +var Reschedule = shortcut.Shortcut{ + Service: "calendar", + Command: "+reschedule", + Product: "calendar", + Description: "改一个已有日程的时间(只动开始/结束时间,其他字段不变)", + Intent: "当你想把一个已经存在的日程改到新的时间段、又不想动标题/描述/参会人等其他内容时使用;" + + "内部先用 eventId 拉一次日程详情确认它真实存在,再只更新开始和结束时间。" + + "如果 eventId 查不到会直接报错,不会误改别的日程。" + + "会真实修改该日程的时间。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "event", Type: shortcut.FlagString, Desc: "要改期的日程 eventId(可用 dws calendar event list 查询)", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "新的开始时间(ISO8601,如 2026-03-10T15:00:00+08:00)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "新的结束时间(ISO8601,如 2026-03-10T16:00:00+08:00)", Required: true}, + }, + Tips: []string{ + `dws calendar +reschedule --event EVENT_ID --start "2026-03-10T15:00:00+08:00" --end "2026-03-10T16:00:00+08:00"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + eventID := strings.TrimSpace(rt.Str("event")) + if eventID == "" { + return apperrors.NewValidation("--event 不能为空") + } + start := strings.TrimSpace(rt.Str("start")) + end := strings.TrimSpace(rt.Str("end")) + if start == "" || end == "" { + return apperrors.NewValidation("--start 与 --end 都必须提供(ISO8601 时间字符串)") + } + + // Step 1 — confirm the event exists. eventId param copied verbatim from the + // helper's `event get` call site (get_calendar_detail). + if _, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{ + "eventId": eventID, + }); err != nil { + return err + } + + // Step 2 — update ONLY the time. eventId + startDateTime/endDateTime (ISO + // strings, not millis) copied verbatim from the helper's `event update` + // call site (update_calendar_event). No other field is passed, so nothing + // else on the event is changed. + return rt.CallMCP("update_calendar_event", map[string]any{ + "eventId": eventID, + "startDateTime": start, + "endDateTime": end, + }) + }, +} + +func init() { + shortcut.Register(Reschedule) +} diff --git a/internal/shortcut/smart/resolve.go b/internal/shortcut/smart/resolve.go new file mode 100644 index 00000000..97f84931 --- /dev/null +++ b/internal/shortcut/smart/resolve.go @@ -0,0 +1,85 @@ +// 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 + 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) { + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ + "keyword": name, + }) + if err != nil { + return contactUser{}, err + } + users := extractUsers(data) + 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。请用更精确的姓名,或直接用对应命令传 userId。", + name, len(users), strings.Join(userLabels(users), "、"))) + } + return users[0], nil +} + +// extractUsers pulls {userId, 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) + if id == "" { + continue + } + nm, _ := m["name"].(string) + out = append(out, contactUser{userID: id, name: nm}) + } + return out +} + +func userLabels(users []contactUser) []string { + out := make([]string, 0, len(users)) + for _, u := range users { + out = append(out, fmt.Sprintf("%s(%s)", u.name, u.userID)) + } + return out +} diff --git a/internal/shortcut/smart/resolve_base.go b/internal/shortcut/smart/resolve_base.go new file mode 100644 index 00000000..90a3afbb --- /dev/null +++ b/internal/shortcut/smart/resolve_base.go @@ -0,0 +1,148 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ResolveBase: resolve a 多维表 Base by name keyword into a single baseId. +// +// This is the Base-level analogue of "resolve a user by name". It searches +// Bases by name and disambiguates: +// - search Bases via search_bases (mirrors helpers base search, MCP arg +// "query" ← --name); +// - project each candidate to {baseId, name} — field parsing is defensive +// (multiple candidate keys); +// - exactly one match → return {resolved:true, baseId, name}; +// multiple matches → return {resolved:false, count, candidates} and let +// the caller pick (never guesses); +// zero matches → report a validation error instead of an empty raw dump. +// +// Read-only: it only searches and reshapes, never mutates any Base. +// +// dws aitable +resolve-base --name 项目管理 +var ResolveBase = shortcut.Shortcut{ + Service: "aitable", + Command: "+resolve-base", + Product: "aitable", + Description: "按名称搜索多维表 Base 并解析出唯一 baseId(只读)", + Intent: "当你只知道某个多维表 Base 的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 baseId 时使用;" + + "内部按 --name 关键词调用 search_bases 搜索 Base,再在本地投影出每个候选的 baseId 和 name。" + + "如果只命中一个 Base 就直接返回它的 baseId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + + "这是纯只读操作,只做搜索与本地投影,不会修改任何 Base。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的 Base 名称关键词(必填)", Required: true}, + }, + Tips: []string{ + `dws aitable +resolve-base --name 项目管理`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("name") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Search Bases by name. tool "search_bases" + arg "query" are taken + // verbatim from helpers base search (callAitableTool → server aitable). + data, err := rt.CallMCPData("aitable", "search_bases", map[string]any{ + "query": rt.Str("name"), + }) + if err != nil { + return err + } + + // Project candidates to {baseId, name}, defensively unwrapping the list. + items := resolveBaseItems(data) + candidates := make([]map[string]any, 0, len(items)) + for _, b := range items { + candidates = append(candidates, map[string]any{ + "baseId": resolveBaseID(b), + "name": resolveBaseName(b), + }) + } + + switch len(candidates) { + case 0: + return apperrors.NewValidation("没有找到名称包含 " + rt.Str("name") + " 的 Base") + case 1: + return rt.Output(map[string]any{ + "resolved": true, + "baseId": candidates[0]["baseId"], + "name": candidates[0]["name"], + }) + default: + return rt.Output(map[string]any{ + "resolved": false, + "count": len(candidates), + "candidates": candidates, + }) + } + }, +} + +// resolveBaseItems defensively unwraps the list of Base candidates from the +// search_bases response, tolerating the common container keys the gateway may +// use. +func resolveBaseItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, key := range []string{"result", "data", "list", "items", "bases", "records"} { + raw, ok := data[key] + if !ok { + continue + } + if list, ok := raw.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for _, e := range list { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out + } + // Nested container, e.g. {"data":{"list":[...]}}. + if nested, ok := raw.(map[string]any); ok { + if inner := resolveBaseItems(nested); len(inner) > 0 { + return inner + } + } + } + return nil +} + +// resolveBaseID reads a Base's identifier, tolerating the common id keys. +func resolveBaseID(b map[string]any) string { + for _, key := range []string{"baseId", "base_id", "id"} { + if s, ok := b[key].(string); ok && s != "" { + return s + } + } + return "" +} + +// resolveBaseName reads a Base's display name, tolerating the common name keys. +func resolveBaseName(b map[string]any) string { + for _, key := range []string{"name", "baseName", "title"} { + if s, ok := b[key].(string); ok && s != "" { + return s + } + } + return "" +} + +func init() { + shortcut.Register(ResolveBase) +} diff --git a/internal/shortcut/smart/resolve_dept.go b/internal/shortcut/smart/resolve_dept.go new file mode 100644 index 00000000..9a28b3ed --- /dev/null +++ b/internal/shortcut/smart/resolve_dept.go @@ -0,0 +1,176 @@ +// 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" + "strconv" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ResolveDept: resolve a department by name keyword into a single deptId. +// +// This is the department-level analogue of "resolve a user by name". It searches +// departments by name and disambiguates: +// - search departments via search_dept_by_keyword (mirrors helpers contact +// dept search, MCP arg "query" ← --name); +// - project each candidate to {deptId, name} — field parsing is defensive +// (multiple candidate keys); +// - exactly one match → return {resolved:true, deptId, name}; +// multiple matches → return {resolved:false, count, candidates} and let +// the caller pick (never guesses); +// zero matches → report a validation error instead of an empty raw dump. +// +// Read-only: it only searches and reshapes, never mutates anything. Unlike +// +dept-members it stops at the deptId and does NOT list members. +// +// dws contact +resolve-dept --name 技术部 +var ResolveDept = shortcut.Shortcut{ + Service: "contact", + Command: "+resolve-dept", + Product: "contact", + Description: "按名称搜索部门并解析出唯一 deptId(只读)", + Intent: "当你只知道某个部门的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 deptId 时使用;" + + "内部按 --name 关键词调用 search_dept_by_keyword 搜索部门,再在本地投影出每个候选的 deptId 和 name。" + + "如果只命中一个部门就直接返回它的 deptId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + + "这是纯只读操作,只做搜索与本地投影,不会修改任何部门,也不会列出部门成员。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的部门名称关键词(必填)", Required: true}, + }, + Tips: []string{ + `dws contact +resolve-dept --name 技术部`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("name") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Search departments by name. tool "search_dept_by_keyword" + arg + // "query" are taken verbatim from helpers contact dept search + // (callMCPTool → server contact). + data, err := rt.CallMCPData("contact", "search_dept_by_keyword", map[string]any{ + "query": rt.Str("name"), + }) + if err != nil { + return err + } + + // Project candidates to {deptId, name}, defensively unwrapping the list. + items := resolveDeptItems(data) + candidates := make([]map[string]any, 0, len(items)) + for _, d := range items { + candidates = append(candidates, map[string]any{ + "deptId": resolveDeptID(d), + "name": resolveDeptName(d), + }) + } + + switch len(candidates) { + case 0: + return apperrors.NewValidation("没有找到名称包含 " + rt.Str("name") + " 的部门") + case 1: + return rt.Output(map[string]any{ + "resolved": true, + "deptId": candidates[0]["deptId"], + "name": candidates[0]["name"], + }) + default: + return rt.Output(map[string]any{ + "resolved": false, + "count": len(candidates), + "candidates": candidates, + }) + } + }, +} + +// resolveDeptItems defensively unwraps the list of department candidates from +// the search_dept_by_keyword response, tolerating the common container keys the +// gateway may use. +func resolveDeptItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + // NB: the real search_dept_by_keyword response wraps candidates in "deptList" + // (verified against the live gateway); keep it first among the probes. + for _, key := range []string{"deptList", "result", "data", "list", "items", "departments", "depts", "records"} { + raw, ok := data[key] + if !ok { + continue + } + if list, ok := raw.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for _, e := range list { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out + } + // Nested container, e.g. {"data":{"list":[...]}}. + if nested, ok := raw.(map[string]any); ok { + if inner := resolveDeptItems(nested); len(inner) > 0 { + return inner + } + } + } + return nil +} + +// resolveDeptID reads a department's identifier, tolerating the common id keys. +// DingTalk deptIds are numeric, so the value arrives as a JSON number (float64) +// rather than a string — coerce it instead of dropping it. +func resolveDeptID(d map[string]any) string { + for _, key := range []string{"deptId", "dept_id", "departmentId", "id"} { + switch v := d[key].(type) { + case string: + if v != "" { + return v + } + case float64: + return strconv.FormatInt(int64(v), 10) + case json.Number: + return v.String() + } + } + return "" +} + +// resolveDeptName reads a department's display name, tolerating the common name +// keys. search_dept_by_keyword wraps the matched substring in … +// highlight markup — strip it so the projected name is clean. +func resolveDeptName(d map[string]any) string { + for _, key := range []string{"name", "deptName", "departmentName", "title"} { + if s, ok := d[key].(string); ok && s != "" { + return stripHighlightTags(s) + } + } + return "" +} + +// stripHighlightTags removes the / (and similar simple) highlight +// tags the DingTalk search gateway injects around matched keywords. +func stripHighlightTags(s string) string { + for _, tag := range []string{"", "", "", "", "", ""} { + s = strings.ReplaceAll(s, tag, "") + } + return s +} + +func init() { + shortcut.Register(ResolveDept) +} diff --git a/internal/shortcut/smart/resolve_space.go b/internal/shortcut/smart/resolve_space.go new file mode 100644 index 00000000..7b9db578 --- /dev/null +++ b/internal/shortcut/smart/resolve_space.go @@ -0,0 +1,149 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ResolveSpace: resolve a wiki 知识空间 by name keyword into a single spaceId. +// +// This is the wiki-space-level analogue of "resolve a user by name". It +// searches knowledge spaces by name and disambiguates: +// - search spaces via search_wikiSpaces (mirrors helpers wiki space search, +// MCP arg "keyword" ← --name); +// - project each candidate to {spaceId, name} — field parsing is defensive +// (multiple candidate keys); +// - exactly one match → return {resolved:true, spaceId, name}; +// multiple matches → return {resolved:false, count, candidates} and let +// the caller pick (never guesses); +// zero matches → report a validation error instead of an empty raw dump. +// +// Read-only: it only searches and reshapes, never mutates any space. +// +// dws wiki +resolve-space --name 产品文档 +var ResolveSpace = shortcut.Shortcut{ + Service: "wiki", + Command: "+resolve-space", + Product: "wiki", + Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", + Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" + + "内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" + + "如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + + "这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true}, + }, + Tips: []string{ + `dws wiki +resolve-space --name 产品文档`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("name") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Search spaces by name. tool "search_wikiSpaces" + arg "keyword" are + // taken verbatim from helpers wiki space search (callMCPTool → server + // wiki). + data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{ + "keyword": rt.Str("name"), + }) + if err != nil { + return err + } + + // Project candidates to {spaceId, name}, defensively unwrapping the list. + items := resolveSpaceItems(data) + candidates := make([]map[string]any, 0, len(items)) + for _, s := range items { + candidates = append(candidates, map[string]any{ + "spaceId": resolveSpaceID(s), + "name": resolveSpaceName(s), + }) + } + + switch len(candidates) { + case 0: + return apperrors.NewValidation("没有找到名称包含 " + rt.Str("name") + " 的知识空间") + case 1: + return rt.Output(map[string]any{ + "resolved": true, + "spaceId": candidates[0]["spaceId"], + "name": candidates[0]["name"], + }) + default: + return rt.Output(map[string]any{ + "resolved": false, + "count": len(candidates), + "candidates": candidates, + }) + } + }, +} + +// resolveSpaceItems defensively unwraps the list of space candidates from the +// search_wikiSpaces response, tolerating the common container keys the gateway +// may use. +func resolveSpaceItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} { + raw, ok := data[key] + if !ok { + continue + } + if list, ok := raw.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for _, e := range list { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out + } + // Nested container, e.g. {"data":{"list":[...]}}. + if nested, ok := raw.(map[string]any); ok { + if inner := resolveSpaceItems(nested); len(inner) > 0 { + return inner + } + } + } + return nil +} + +// resolveSpaceID reads a space's identifier, tolerating the common id keys. +func resolveSpaceID(s map[string]any) string { + for _, key := range []string{"spaceId", "space_id", "id"} { + if v, ok := s[key].(string); ok && v != "" { + return v + } + } + return "" +} + +// resolveSpaceName reads a space's display name, tolerating the common name keys. +func resolveSpaceName(s map[string]any) string { + for _, key := range []string{"name", "spaceName", "title"} { + if v, ok := s[key].(string); ok && v != "" { + return v + } + } + return "" +} + +func init() { + shortcut.Register(ResolveSpace) +} diff --git a/internal/shortcut/smart/resolve_table.go b/internal/shortcut/smart/resolve_table.go new file mode 100644 index 00000000..9b100770 --- /dev/null +++ b/internal/shortcut/smart/resolve_table.go @@ -0,0 +1,183 @@ +// 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 ( + "strconv" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ResolveTable: resolve a 数据表 (table) inside one Base by name keyword into a +// single tableId. +// +// This is the table-level analogue of "resolve a Base by name". Because there is +// no server tool that searches tables by name, it lists every table in the Base +// via get_tables (baseId ← --base, verbatim from list_tables / helpers +// tableGetCmd) and then matches --name locally: +// - project each table to {tableId, name} — field parsing is defensive +// (multiple candidate keys); +// - filter locally by a case-insensitive substring match on name; +// - exactly one match → return {resolved:true, tableId, name, base}; +// multiple matches → return {resolved:false, count, candidates} and let the +// caller pick (never guesses); +// zero matches → report a validation error instead of an empty raw dump. +// +// Read-only: it only lists and reshapes, never mutates any table. +// +// dws aitable +resolve-table --base B --name 任务 +var ResolveTable = shortcut.Shortcut{ + Service: "aitable", + Command: "+resolve-table", + Product: "aitable", + Description: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)", + Intent: "当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称或名称关键词、" + + "想把它解析成可直接用于后续工具的 tableId 时使用;" + + "内部先用 get_tables(只传 baseId)列出该 Base 下的全部数据表,再在本地把每张表投影成 tableId、name," + + "并按 --name 关键词做大小写不敏感的包含匹配来筛选候选。" + + "如果只命中一张表就直接返回它的 tableId;如果命中多张则列出全部候选让你消歧,绝不替你瞎猜;如果一张都没命中则提示未找到。" + + "这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(要在其内解析数据表的多维表)", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "要匹配的数据表名称关键词(必填)", Required: true}, + }, + Tips: []string{ + `dws aitable +resolve-table --base B --name 任务`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("base", "name") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // baseId mirrors list_tables / helpers tableGetCmd (get_tables). Omitting + // tableIds asks the server for every table in the base. + data, err := rt.CallMCPData("aitable", "get_tables", map[string]any{ + "baseId": rt.Str("base"), + }) + if err != nil { + return err + } + + // Project every table to {tableId, name}, defensively unwrapping the list. + items := resolveTableItems(data) + all := make([]map[string]any, 0, len(items)) + for _, t := range items { + all = append(all, map[string]any{ + "tableId": resolveTableID(t), + "name": resolveTableName(t), + }) + } + + // Filter locally by case-insensitive substring match on name. + needle := strings.ToLower(rt.Str("name")) + candidates := make([]map[string]any, 0, len(all)) + for _, c := range all { + name, _ := c["name"].(string) + if strings.Contains(strings.ToLower(name), needle) { + candidates = append(candidates, c) + } + } + + switch len(candidates) { + case 0: + return apperrors.NewValidation("Base 内没有名称包含 " + rt.Str("name") + " 的数据表") + case 1: + return rt.Output(map[string]any{ + "resolved": true, + "tableId": candidates[0]["tableId"], + "name": candidates[0]["name"], + "base": rt.Str("base"), + }) + default: + return rt.Output(map[string]any{ + "resolved": false, + "count": len(candidates), + "candidates": candidates, + }) + } + }, +} + +// resolveTableItems defensively unwraps the list of tables from a get_tables +// response, tolerating the common container keys the gateway may use. +func resolveTableItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, key := range []string{"result", "data", "list", "items", "tables", "records"} { + raw, ok := data[key] + if !ok { + continue + } + if list, ok := raw.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for _, e := range list { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out + } + // Nested container, e.g. {"data":{"list":[...]}}. + if nested, ok := raw.(map[string]any); ok { + if inner := resolveTableItems(nested); len(inner) > 0 { + return inner + } + } + } + return nil +} + +// resolveTableID reads a table's identifier, tolerating the common id keys. +func resolveTableID(t map[string]any) string { + for _, key := range []string{"tableId", "table_id", "id"} { + if s := resolveTableString(t[key]); s != "" { + return s + } + } + return "" +} + +// resolveTableName reads a table's display name, tolerating the common name keys. +func resolveTableName(t map[string]any) string { + for _, key := range []string{"name", "tableName", "table_name", "title"} { + if s := resolveTableString(t[key]); s != "" { + return s + } + } + return "" +} + +// resolveTableString coerces a scalar JSON value to a trimmed string, returning +// "" for nil / non-scalar / empty values. +func resolveTableString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func init() { + shortcut.Register(ResolveTable) +} diff --git a/internal/shortcut/smart/respond_event.go b/internal/shortcut/smart/respond_event.go new file mode 100644 index 00000000..e72d90a5 --- /dev/null +++ b/internal/shortcut/smart/respond_event.go @@ -0,0 +1,91 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// RespondEvent: accept / decline / tentatively respond to a calendar event +// invitation in one command, as the current user (the event attendee). +// +// This wraps the `respond` MCP tool from helpers/calendar.go verbatim +// (mirroring `dws calendar event respond`): it takes eventId + responseStatus, +// where responseStatus is one of needsAction/accepted/declined/tentative. The +// shortcut exposes the ergonomic verbs accept/decline/tentative on --response +// and maps them onto the tool's accepted/declined/tentative values so the +// caller never has to remember the past-tense wire spelling. +// +// dws calendar +respond-event --event EVENT_ID --response accept +// dws calendar +respond-event --event EVENT_ID --response decline +// dws calendar +respond-event --event EVENT_ID --response tentative +var RespondEvent = shortcut.Shortcut{ + Service: "calendar", + Command: "+respond-event", + Product: "calendar", + Description: "接受 / 拒绝 / 暂定回复一个日程邀请(作为参会人设置自己的响应状态)", + Intent: "当你收到一个日程邀请、想直接确认接受、婉拒或标记为暂定,而不想记忆 accepted/declined 这类过去式接口取值时使用;" + + "你只需提供日程的 eventId(用 `dws calendar event list` 查询)和一个直白的动作 accept/decline/tentative," + + "内部会把它映射为服务端的 accepted/declined/tentative 并调用日历的 respond 能力设置你的参会响应状态。" + + "注意:这会真实修改你在该日程上的响应状态;订阅日历下的日程没有参会人,因此无法响应。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "event", Type: shortcut.FlagString, Desc: "日程 eventId(用 `dws calendar event list` 查询)", Required: true}, + {Name: "response", Type: shortcut.FlagString, Desc: "响应动作:accept(接受) / decline(拒绝) / tentative(暂定)", Required: true, Enum: []string{"accept", "decline", "tentative"}}, + }, + Tips: []string{ + `dws calendar +respond-event --event EVENT_ID --response accept`, + `dws calendar +respond-event --event EVENT_ID --response decline`, + `dws calendar +respond-event --event EVENT_ID --response tentative`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("event", "response") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + eventID := strings.TrimSpace(rt.Str("event")) + if eventID == "" { + return apperrors.NewValidation("请用 --event 提供日程的 eventId(可用 `dws calendar event list` 查询)") + } + + // Map the ergonomic verb onto the tool's responseStatus wire value. + // The `respond` tool accepts accepted/declined/tentative (see + // helpers/calendar.go event respond); the Enum on --response already + // guarantees one of accept/decline/tentative here. + var status string + switch strings.TrimSpace(strings.ToLower(rt.Str("response"))) { + case "accept": + status = "accepted" + case "decline": + status = "declined" + case "tentative": + status = "tentative" + default: + return apperrors.NewValidation("--response 只允许 accept / decline / tentative") + } + + // Call `respond` verbatim: eventId + responseStatus, mirroring + // `dws calendar event respond`. + return rt.CallMCP("respond", map[string]any{ + "eventId": eventID, + "responseStatus": status, + }) + }, +} + +func init() { + shortcut.Register(RespondEvent) +} diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go new file mode 100644 index 00000000..9721cd54 --- /dev/null +++ b/internal/shortcut/smart/search_mail.go @@ -0,0 +1,217 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// SearchMail: search a mailbox by keyword and project a compact list in one step. +// +// Steps: +// +// 1. resolve the mailbox address — use --email when given, otherwise pick the +// current user's first bound mailbox via list_user_mailboxes; +// +// 2. search that mailbox via search_emails (email / query / size mirror +// helpers.messageSearch; size defaults to "20" as a string, matching the +// helper's sizeVal handling); +// +// 3. in Go, project each returned message to {subject, from, date, messageId} +// and print the list via rt.Output so it honours --format/--jq/--fields. +// +// Read-only: it only lists and projects, never mutating any mail. +// +// dws mail +search-mail --query "subject:周报" +// dws mail +search-mail --query "from:alice AND date>2025-06-01T00:00:00Z" +// dws mail +search-mail --email user@company.com --query "hasAttachments:true" +var SearchMail = shortcut.Shortcut{ + Service: "mail", + Command: "+search-mail", + Product: "mail", + Description: "按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)", + Intent: "当你想按关键词(KQL 表达式,如 subject:周报、from:alice、hasAttachments:true、folderId:2 等)快速搜自己的邮件、" + + "并只看一份精简清单(主题、发件人、时间、邮件 messageId)而不想翻完整正文时使用;" + + "内部先确定要搜的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再执行邮件搜索," + + "最后在本地把每封邮件投影成 {subject, from, date, messageId} 打印出来,可配合 --format/--jq/--fields。" + + "这是纯只读操作,只做搜索与本地投影,不会修改、发送或删除任何邮件;若没有命中则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, + {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + }, + Tips: []string{ + `dws mail +search-mail --query "subject:周报"`, + `dws mail +search-mail --query "from:alice AND date>2025-06-01T00:00:00Z"`, + `dws mail +search-mail --email user@company.com --query "hasAttachments:true"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + if err := rt.RequireAll("query"); err != nil { + return err + } + + // Step 1 — resolve the mailbox address. + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + + // Step 2 — search that mailbox. email/query/size mirror the search_emails + // call in helpers.messageSearch; size is passed as a string. + size := rt.Str("size") + if size == "" { + size = "20" + } + data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + "email": email, + "query": rt.Str("query"), + "size": size, + }) + if err != nil { + return err + } + + // Step 3 — project each message to a compact record. + messages := searchMailMessages(data) + out := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + out = append(out, map[string]any{ + "subject": searchMailFirstString(m, "subject", "title", "topic"), + "from": searchMailFrom(m), + "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), + "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), + }) + } + return rt.Output(map[string]any{"messages": out, "email": email}) + }, +} + +// searchMailFirstMailbox lists the current user's bound mailboxes via +// list_user_mailboxes and returns the first mailbox address. The gateway wraps +// the list under "mailboxes" (per helper docs); we probe a few container keys +// and address field names defensively. +func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { + data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil) + if err != nil { + return "", err + } + boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records") + for _, m := range boxes { + if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { + return addr, nil + } + } + // Some gateways return the addresses as a bare string list. + for _, key := range []string{"mailboxes", "list", "items", "data", "result"} { + if arr, ok := data[key].([]any); ok { + for _, it := range arr { + if s, ok := it.(string); ok && s != "" { + return s, nil + } + } + } + } + return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") +} + +// searchMailMessages extracts the message list from a search_emails response. +// The helper documents the list under "messages". +func searchMailMessages(data map[string]any) []map[string]any { + return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records") +} + +// searchMailUnwrapList probes the given container keys at the top level, and one +// level deep, returning the first list found as []map[string]any. +func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any { + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return searchMailToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range keys { + if arr, ok := inner[k2].([]any); ok { + return searchMailToMaps(arr) + } + } + } + } + return nil +} + +func searchMailToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// searchMailFrom reads a message's sender, tolerating both a plain string and a +// nested object ({name, email/address}). +func searchMailFrom(m map[string]any) any { + switch v := m["from"].(type) { + case string: + if v != "" { + return v + } + case map[string]any: + name := searchMailFirstString(v, "name", "displayName") + addr := searchMailFirstString(v, "email", "emailAddress", "address") + switch { + case name != "" && addr != "": + return name + " <" + addr + ">" + case addr != "": + return addr + case name != "": + return name + } + } + if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { + return s + } + return "" +} + +func searchMailFirstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if s, ok := m[key].(string); ok && s != "" { + return s + } + } + return "" +} + +func searchMailFirstAny(m map[string]any, keys ...string) any { + for _, key := range keys { + if v, ok := m[key]; ok && v != nil { + if s, isStr := v.(string); isStr && s == "" { + continue + } + return v + } + } + return nil +} + +func init() { + shortcut.Register(SearchMail) +} diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go new file mode 100644 index 00000000..e9b8aa47 --- /dev/null +++ b/internal/shortcut/smart/search_msg.go @@ -0,0 +1,237 @@ +// 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 ( + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// SearchMsg: search messages inside a single group chat by keyword in one step. +// +// Steps: +// 1. compute the look-back window [now-Nd, now] in local time and express both +// bounds as epoch millis. N defaults to 7 days and is overridable via +// --days; mirroring `dws chat message search`, which feeds startTime/endTime +// as epoch millis to search_messages_by_keyword. +// 2. call search_messages_by_keyword on the chat server with keyword/startTime/ +// endTime/limit/cursor plus openConversationId — the exact parameter names +// and first-page defaults (limit 100, cursor "0") used by +// helpers.chatMessageSearchCmd. --query maps to keyword, --group maps to +// openConversationId. +// 3. defensively project each returned message down to {sender, time, text, +// messageId} (multiple candidate keys per field) and print via rt.Output so +// it honours --format/--jq/--fields. When the response carries no +// recognisable message list we fall back to printing the raw payload. +// +// This replaces manually working out the millisecond time window and copying the +// search incantation. Read-only: it only searches and reshapes, never sends, +// recalls or marks anything. +// +// dws chat +search-msg --group --query "changefree" +// dws chat +search-msg --group --query "周报" --days 3 +var SearchMsg = shortcut.Shortcut{ + Service: "chat", + Command: "+search-msg", + Product: "chat", + Description: "在群内按关键词搜消息(默认近 7 天,投影发送人/时间/内容/messageId)", + Intent: "当你想在某个群里按关键词翻最近的聊天记录、但不想手动把起止时间换算成毫秒、也不想记 message search 的一堆参数时使用;" + + "用 --group 指定群的 openConversationId、--query 指定搜索关键词,内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗," + + "搜索这段时间内该群里命中关键词的消息,再在本地把每条消息投影成发送人、时间、内容、messageId 四个关键字段。" + + "这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群会话的 openConversationId(必填)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(必填)", Required: true}, + {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false}, + }, + Tips: []string{ + `dws chat +search-msg --group --query "changefree"`, + `dws chat +search-msg --group --query "周报" --days 3`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + group := strings.TrimSpace(rt.Str("group")) + query := strings.TrimSpace(rt.Str("query")) + + // Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults + // to 7; guard against non-positive overrides so the window stays sane. + days := rt.Int("days") + if days <= 0 { + days = 7 + } + now := time.Now() + startMs := now.AddDate(0, 0, -days).UnixMilli() + endMs := now.UnixMilli() + + // Step 2 — search messages by keyword within the group. keyword/startTime/ + // endTime/limit/cursor + openConversationId and the first-page defaults + // (limit 100, cursor "0") mirror helpers.chatMessageSearchCmd's + // search_messages_by_keyword call. + data, err := rt.CallMCPData("chat", "search_messages_by_keyword", map[string]any{ + "keyword": query, + "startTime": startMs, + "endTime": endMs, + "limit": 100, + "cursor": "0", + "openConversationId": group, + }) + if err != nil { + return err + } + + // Step 3 — project matched messages; fall back to the raw payload when we + // cannot locate a recognisable message list. + items := searchMsgItems(data) + if len(items) == 0 { + return rt.Output(data) + } + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "sender": searchMsgSender(m), + "time": searchMsgTime(m), + "text": searchMsgText(m), + "messageId": searchMsgMessageID(m), + }) + } + return rt.Output(map[string]any{"messages": results}) + }, +} + +// searchMsgItems locates the message list inside a search_messages_by_keyword +// response, probing common container keys at the top level and nested under +// "result". Returns nil when no list is found. +func searchMsgItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return searchMsgToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} { + if arr, ok := inner[k2].([]any); ok { + return searchMsgToMaps(arr) + } + } + } + } + return nil +} + +func searchMsgToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// searchMsgSender reads a message's sender display name/id, tolerating the +// common sender keys the gateway may use (including a nested sender object). +func searchMsgSender(m map[string]any) any { + for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} { + if v := searchMsgString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"sender", "from", "senderUser"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} { + if v := searchMsgString(nested[k2]); v != "" { + return v + } + } + } + if v := searchMsgString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { + if v := searchMsgString(m[key]); v != "" { + return v + } + } + return nil +} + +// searchMsgTime reads a message's send time, returning the raw value (usually +// epoch millis) under whichever candidate key is present. +func searchMsgTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "time", "msgTime", "createAt"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +// searchMsgText reads a message's textual content, tolerating flat text keys and +// a nested content/text object. +func searchMsgText(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body"} { + if v := searchMsgString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"content", "text", "msg"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"text", "content", "richText", "title"} { + if v := searchMsgString(nested[k2]); v != "" { + return v + } + } + } + } + return nil +} + +// searchMsgMessageID reads a message's identifier, tolerating the common id keys +// the gateway may use. +func searchMsgMessageID(m map[string]any) any { + for _, key := range []string{"messageId", "message_id", "msgId", "msg_id", "openMessageId", "id"} { + if v := searchMsgString(m[key]); v != "" { + return v + } + } + return nil +} + +// searchMsgString coerces a scalar JSON value to a trimmed string, returning "" +// for nil / non-scalar / empty values. +func searchMsgString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func init() { + shortcut.Register(SearchMsg) +} diff --git a/internal/shortcut/smart/send_to_group.go b/internal/shortcut/smart/send_to_group.go new file mode 100644 index 00000000..ec090ad0 --- /dev/null +++ b/internal/shortcut/smart/send_to_group.go @@ -0,0 +1,148 @@ +// 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" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// SendToGroup: message a group by its NAME, no openConversationId juggling. +// +// Steps: search groups by name → resolve to a single openConversationId +// (disambiguate on multiple matches, never guess) → send a markdown message. +// Replaces `chat search --query <群名>` (copy openConversationId) → +// `chat +messages-send --group `. +// +// 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 +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度" +var SendToGroup = shortcut.Shortcut{ + Service: "chat", + Command: "+send-to-group", + Product: "chat", + Description: "按群名直接给群发消息(自动搜群解析 openConversationId)", + Intent: "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;" + + "内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true}, + }, + Tips: []string{`dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + groupName := rt.Str("group") + text := rt.Str("text") + + // Step 1 — search groups by name (keyword) on the im server. + data, err := rt.CallMCPData("im", "search_groups", map[string]any{ + "keyword": groupName, + "limit": 10, + "cursor": "0", + }) + if err != nil { + return err + } + groups := extractGroupsForSend(data) + switch { + case len(groups) == 0: + return apperrors.NewValidation(fmt.Sprintf( + "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) + case len(groups) > 1: + return apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat +messages-send --group 指定群。", + groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) + } + + // Step 2 — send the markdown message to the unique group. + content, _ := json.Marshal(map[string]string{"title": text, "text": text}) + return rt.CallMCP("send_personal_message", map[string]any{ + "openConversationId": groups[0].id, + "msgType": "markdown", + "content": string(content), + }) + }, +} + +// sendGroupMatch is a single group candidate resolved from a name search. +type sendGroupMatch struct { + id string + name string +} + +// extractGroupsForSend pulls {openConversationId, title} out of a search_groups +// response. The result may be a bare list, or nested under +// result/result.items/result.groups (field names per chat search's real +// response shape), and the name field may be "title" or "name". +func extractGroupsForSend(data map[string]any) []sendGroupMatch { + var list []any + switch inner := data["result"].(type) { + case []any: + list = inner + case map[string]any: + if v, ok := inner["items"].([]any); ok { + list = v + } else if v, ok := inner["groups"].([]any); ok { + list = v + } + } + if list == nil { + if v, ok := data["items"].([]any); ok { + list = v + } + } + + var out []sendGroupMatch + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + continue + } + id, _ := m["openConversationId"].(string) + if id == "" { + id, _ = m["id"].(string) + } + if id == "" { + continue + } + name, _ := m["title"].(string) + if name == "" { + name, _ = m["name"].(string) + } + out = append(out, sendGroupMatch{id: id, name: name}) + } + return out +} + +func sendGroupLabels(groups []sendGroupMatch) []string { + out := make([]string, 0, len(groups)) + for _, g := range groups { + name := g.name + if name == "" { + name = "(未命名群)" + } + out = append(out, fmt.Sprintf("%s(%s)", name, g.id)) + } + return out +} + +func init() { + shortcut.Register(SendToGroup) +} diff --git a/internal/shortcut/smart/share_doc.go b/internal/shortcut/smart/share_doc.go new file mode 100644 index 00000000..0e3f6a7d --- /dev/null +++ b/internal/shortcut/smart/share_doc.go @@ -0,0 +1,86 @@ +// 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" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ShareDoc: send a document link to a person by NAME, no ID juggling. +// +// Steps: resolve name → single userId (disambiguate on multiple matches) → +// build a Markdown message that links to the document → send it as a single-chat +// message. This is a pure "share a link" flow: it does not touch any doc tool, +// it just delivers the URL you already have straight to the recipient's inbox. +// +// dws doc +share-doc --to 张三 --url https://docs.dingtalk.com/xxx --note "帮忙过一下" +var ShareDoc = shortcut.Shortcut{ + Service: "doc", + Command: "+share-doc", + Product: "chat", + Description: "按姓名把文档链接私信发给某人(自动解析 userId)", + Intent: "当你手上已经有一个文档链接、想直接私信发给某个人而不必先查 userId 时使用;" + + "内部先按姓名搜通讯录解析出唯一 userId,再把链接拼成一条 Markdown 消息发出去," + + "姓名匹配到多人时会列出候选让你区分。只发链接、不读取或改动文档本身,会真实发出消息。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true}, + {Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true}, + {Name: "note", Type: shortcut.FlagString, Desc: "附言(可选)"}, + }, + Tips: []string{`dws doc +share-doc --to 张三 --url https://docs.dingtalk.com/xxx --note "帮忙过一下"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + url := rt.Str("url") + note := rt.Str("note") + + // Step 1 — resolve the recipient name to a unique userId. + user, err := resolveUser(rt, rt.Str("to")) + if err != nil { + return err + } + + // Step 2 — build a Markdown message linking to the document. + text := shareDocBuildText(url, note) + title := "文档分享" + content, _ := json.Marshal(map[string]string{"title": title, "text": text}) + + // Step 3 — deliver it as a single-chat message. + return rt.CallMCP("send_personal_message", map[string]any{ + "receiverUserId": user.userID, + "msgType": "markdown", + "content": string(content), + }) + }, +} + +// shareDocBuildText assembles the Markdown body: a clickable link plus an +// optional note on its own line. +func shareDocBuildText(url, note string) string { + var b strings.Builder + b.WriteString("[文档](") + b.WriteString(url) + b.WriteString(")") + if strings.TrimSpace(note) != "" { + b.WriteString("\n\n") + b.WriteString(note) + } + return b.String() +} + +func init() { + shortcut.Register(ShareDoc) +} diff --git a/internal/shortcut/smart/suggest_time.go b/internal/shortcut/smart/suggest_time.go new file mode 100644 index 00000000..e8d32999 --- /dev/null +++ b/internal/shortcut/smart/suggest_time.go @@ -0,0 +1,141 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// SuggestTime: recommend meeting slots for several people, resolved BY NAME. +// +// Steps: parse the --with name CSV → resolve each name to a unique userId +// (fails listing every name that can't be resolved) → ask the calendar service +// for suggested slots free for everyone in the given range. Replaces looking up +// each userId by hand and then calling `calendar event suggest --users ...`. +// +// dws calendar +suggest-time --with 张三,李四 \ +// --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 60 +var SuggestTime = shortcut.Shortcut{ + Service: "calendar", + Command: "+suggest-time", + Product: "calendar", + Description: "按姓名解析多位参与者,推荐大家都有空的可开会时间段(自动解析 userId)", + Intent: "当你想为几个人凑一个大家都空闲的开会时间、但只知道他们的姓名而不想逐个手动查 userId 时使用;" + + "内部会把 --with 里的姓名逐个搜通讯录解析成唯一 userId(任何一个没匹配到或匹配到多人都会明确报出来,绝不瞎猜)," + + "再基于所有参与者的忙闲,在给定时间范围内推荐若干可用时段。只读,不创建任何日程。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "with", Type: shortcut.FlagStringSlice, Desc: "参与者姓名(逗号分隔的 CSV,如 张三,李四)", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "时间范围开始(ISO8601,如 2026-03-10T09:00:00+08:00)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "时间范围结束(ISO8601,如 2026-03-10T18:00:00+08:00)", Required: true}, + {Name: "duration", Type: shortcut.FlagString, Desc: "会议时长(分钟,可选)", Required: false}, + }, + Tips: []string{ + `dws calendar +suggest-time --with 张三,李四 --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00"`, + `dws calendar +suggest-time --with 张三,李四,王五 --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 30`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve every participant name to a unique userId. + names := rt.StrSlice("with") + if len(names) == 0 { + return apperrors.NewValidation("--with 至少需要一个参与者姓名") + } + userIDs := make([]string, 0, len(names)) + var failures []string + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + user, err := resolveUser(rt, name) + if err != nil { + failures = append(failures, err.Error()) + continue + } + userIDs = append(userIDs, user.userID) + } + if len(failures) > 0 { + return apperrors.NewValidation( + "以下参与者无法解析为唯一 userId:\n- " + strings.Join(failures, "\n- ")) + } + if len(userIDs) == 0 { + return apperrors.NewValidation("--with 至少需要一个有效的参与者姓名") + } + + // Step 2 — assemble the suggest-times request exactly as the calendar + // helper does (start/end are ISO8601 strings, attendeeUserIds is a + // string slice, durationMinutes is an optional string). + params := map[string]any{ + "start": rt.Str("start"), + "end": rt.Str("end"), + "attendeeUserIds": userIDs, + } + if rt.Changed("duration") { + params["durationMinutes"] = rt.Str("duration") + } + + // Step 3 — recommend slots free for everyone over the range, then project + // result.recommendEventTimes[] down to a clean {start,end,conflicts} list + // (dropping the null padding entries the gateway leaves in + // timeConflictAttendees). + data, err := rt.CallMCPData("calendar", "list_suggested_event_times", params) + if err != nil { + return err + } + return rt.Output(map[string]any{"suggestions": suggestTimeSlots(data)}) + }, +} + +// suggestTimeSlots flattens a list_suggested_event_times response +// (result.recommendEventTimes[]) into a clean {start,end,conflicts} list. Null +// entries in timeConflictAttendees are dropped so "conflicts" only appears when +// there is a real conflicting attendee. +func suggestTimeSlots(data map[string]any) []map[string]any { + out := []map[string]any{} + root, ok := data["result"].(map[string]any) + if !ok { + root = data + } + slots, _ := root["recommendEventTimes"].([]any) + for _, s := range slots { + sm, ok := s.(map[string]any) + if !ok { + continue + } + row := map[string]any{ + "start": sm["startTime"], + "end": sm["endTime"], + } + if raw, ok := sm["timeConflictAttendees"].([]any); ok { + conflicts := []any{} + for _, c := range raw { + if c != nil { + conflicts = append(conflicts, c) + } + } + if len(conflicts) > 0 { + row["conflicts"] = conflicts + } + } + out = append(out, row) + } + return out +} + +func init() { + shortcut.Register(SuggestTime) +} diff --git a/internal/shortcut/smart/team.go b/internal/shortcut/smart/team.go new file mode 100644 index 00000000..ef53f3f7 --- /dev/null +++ b/internal/shortcut/smart/team.go @@ -0,0 +1,86 @@ +// 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" + "strconv" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Team: list the members of the department a person belongs to, by NAME. +// +// Steps: resolve the person's name → userId → fetch their org detail +// (get_user_info_by_user_ids) → defensively parse the primary deptId out of +// orgEmployeeModel.depts → print that department's direct members +// (get_dept_members_by_deptId). Replaces the manual dance of +// `contact user search` → copy userId → `contact user get --ids ` → +// copy deptId → `contact dept list-members --depts `. +// +// 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 +team --name 张三 +var Team = shortcut.Shortcut{ + Service: "contact", + Command: "+team", + Product: "contact", + Description: "按姓名列出某人所在部门的成员(自动解析 userId 与 deptId)", + Intent: "当你只知道某位同事的姓名、想知道 TA 所在部门里都有哪些成员时使用;" + + "内部先按姓名解析出唯一 userId,再取 TA 的组织信息拿到主部门 deptId," + + "最后打印该部门的直接成员列表(仅本部门,不递归下级部门)。只读,不做任何修改。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "同事姓名/花名", Required: true}, + }, + Tips: []string{`dws contact +team --name 张三`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the person's name to a unique userId. + user, err := resolveUser(rt, rt.Str("name")) + if err != nil { + return err + } + + // Step 2 — fetch that user's org-management detail; it carries the + // department list under orgEmployeeModel.depts. + data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{user.userID}, + }) + if err != nil { + return err + } + + // Step 3 — defensively pull the primary deptId out of the response + // (reuses the same extractor as +org), then list that department's + // direct members. + deptID, ok := shortcutOrgExtractDeptID(data) + if !ok { + return apperrors.NewValidation(fmt.Sprintf( + "没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+ + "TA 可能没有归属部门,或返回结构与预期不符。", + user.name, user.userID)) + } + + // 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(deptID, 10)}, + }) + }, +} + +func init() { + shortcut.Register(Team) +} diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go new file mode 100644 index 00000000..dc5594e4 --- /dev/null +++ b/internal/shortcut/smart/this_month_attendance.go @@ -0,0 +1,91 @@ +// 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 ( + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ThisMonthAttendance: show MY punch-in (打卡流水) records for THIS MONTH in one +// step. It is the month-scoped sibling of +my-attendance (which covers today). +// +// Steps: +// 1. resolve the current logged-in user's userId via the contact server's +// zero-arg get_current_user_profile (no --name needed — it's always "me"); +// 2. compute this month's window [1st 00:00, next-month 1st 00:00) in the local +// timezone and format both bounds as "yyyy-MM-dd HH:mm:ss" (the format the +// query_check_record helper feeds the tool); +// 3. call query_check_record on the attendance-wukong server with the exact +// nested QueryCheckRecordRequest shape used by +// `dws attendance check record`, then print via rt.Output. +// +// The month window spans at most 31 days, within query_check_record's span cap. +// Read-only; it never modifies any attendance data. +// +// dws attendance +this-month +var ThisMonthAttendance = shortcut.Shortcut{ + Service: "attendance", + Command: "+this-month", + Product: "attendance", + Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", + Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + + "再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," + + "最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{}, + Tips: []string{ + `dws attendance +this-month`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the current user's own userId (zero-arg "我的" API). + profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + if err != nil { + return err + } + userID := myAttendanceCurrentUserID(profile) + if userID == "" { + return apperrors.NewValidation( + "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") + } + + // Step 2 — this month's window [1st 00:00, next-month 1st 00:00) in local + // time, formatted as the tool expects (yyyy-MM-dd HH:mm:ss). + now := time.Now() + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 1, 0) + const layout = "2006-01-02 15:04:05" + + // Step 3 — query my punch-in records for this month. The nested + // QueryCheckRecordRequest shape + field names mirror the + // `dws attendance check record` helper exactly. + data, err := rt.CallMCPData("attendance-wukong", "query_check_record", map[string]any{ + "QueryCheckRecordRequest": map[string]any{ + "userIds": []string{userID}, + "checkDateFrom": start.Format(layout), + "checkDateTo": end.Format(layout), + }, + }) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +func init() { + shortcut.Register(ThisMonthAttendance) +} diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go new file mode 100644 index 00000000..6bd72465 --- /dev/null +++ b/internal/shortcut/smart/thread_replies.go @@ -0,0 +1,181 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ThreadReplies: fetch every reply under one topic ("话题") message and print a +// clean projected list (speaker / text / time) instead of a raw dump. +// +// Steps: +// 1. call list_topic_replies (chat server) with openconversationId=--group and +// topicId=--topic-id, optionally startTime=--time and pageSize=--limit — +// param keys copied verbatim from chat.go's list-topic-replies call site; +// 2. defensively unwrap the reply list (multiple candidate container keys) and +// project each reply to {sender, text, createTime} tolerating field aliases; +// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields. +// +// Read-only: it only reads a topic's replies and reshapes them locally, never +// posts or mutates anything. +// +// dws chat +thread-replies --group --topic-id +var ThreadReplies = shortcut.Shortcut{ + Service: "chat", + Command: "+thread-replies", + Product: "chat", + Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间", + Intent: "当你已经拿到某个群里一条「话题消息」的 topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," + + "而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --topic-id(话题 ID)拉取该话题的回复列表," + + "可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" + + "这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true}, + {Name: "topic-id", Type: shortcut.FlagString, Desc: "话题 ID(由 dws chat message list 返回,必填)", Required: true}, + {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"}, + }, + Tips: []string{ + `dws chat +thread-replies --group --topic-id `, + `dws chat +thread-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return rt.RequireAll("group", "topic-id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — fetch the topic replies. Param keys (openconversationId / + // topicId / startTime / pageSize) are copied verbatim from chat.go's + // list-topic-replies call site. + params := map[string]any{ + "openconversationId": rt.Str("group"), + "topicId": rt.Str("topic-id"), + "forward": false, + } + if rt.Changed("time") && rt.Str("time") != "" { + params["startTime"] = rt.Str("time") + } + if rt.Changed("limit") && rt.Int("limit") > 0 { + params["pageSize"] = rt.Int("limit") + } + + data, err := rt.CallMCPData("chat", "list_topic_replies", params) + if err != nil { + return err + } + + // Step 2 — defensively unwrap and project. Response shape has no + // contract, so probe multiple candidate container/field keys. + items := threadReplyItems(data) + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + results = append(results, map[string]any{ + "sender": threadReplySender(m), + "text": threadReplyText(m), + "createTime": threadReplyCreateTime(m), + }) + } + + return rt.Output(map[string]any{ + "replies": results, + "count": len(results), + }) + }, +} + +// threadReplyItems defensively unwraps the reply list from the response, +// tolerating the common container keys and one level of nesting under a +// "result"/"data" wrapper. +func threadReplyItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + // Try the list keys directly on the response, then inside a wrapper. + scopes := []map[string]any{data} + for _, wrap := range []string{"result", "data"} { + if inner, ok := data[wrap].(map[string]any); ok { + scopes = append(scopes, inner) + } + } + for _, scope := range scopes { + for _, key := range []string{"replies", "list", "items", "messages", "records", "data", "result"} { + if raw, ok := scope[key].([]any); ok { + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + if len(out) > 0 { + return out + } + } + } + } + return nil +} + +// threadReplySender reads a reply's speaker display name, tolerating common +// sender-name keys. +func threadReplySender(m map[string]any) any { + for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { + if v, ok := m[key]; ok && v != nil { + if s, ok := v.(string); ok && s == "" { + continue + } + return v + } + } + return nil +} + +// threadReplyText reads a reply's textual content, tolerating common text keys +// and one level of nesting (e.g. {"text":{"content":"..."}}). +func threadReplyText(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t != "" { + return t + } + case map[string]any: + for _, inner := range []string{"content", "text", "value"} { + if s, ok := t[inner].(string); ok && s != "" { + return s + } + } + } + } + return nil +} + +// threadReplyCreateTime reads a reply's create/send time, returning the raw +// value under whichever candidate key is present. +func threadReplyCreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +func init() { + shortcut.Register(ThreadReplies) +} diff --git a/internal/shortcut/smart/today.go b/internal/shortcut/smart/today.go new file mode 100644 index 00000000..7900c5b8 --- /dev/null +++ b/internal/shortcut/smart/today.go @@ -0,0 +1,70 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Today: list the current user's calendar events for today, with the day +// boundaries computed automatically so the caller never has to hand-craft +// ISO/millisecond time ranges. +// +// It resolves "today" from the machine's local clock: startTime = today 00:00, +// endTime = tomorrow 00:00 (local timezone), both converted to epoch +// milliseconds — exactly the int64 shape the `list_calendar_events` tool +// expects at its helper call site (parseISOTimeToMillis -> millis). calendarId +// defaults to the primary calendar. Replaces the manual +// `calendar event list --start ... --end ...` where you must format two +// timezone-aware timestamps by hand. +// +// dws calendar +today +var Today = shortcut.Shortcut{ + Service: "calendar", + Command: "+today", + Product: "calendar", + Description: "列出我今天的日程(自动计算今天的起止时间,无需手动填时间范围)", + Intent: "当你想快速看看『我今天有哪些日程/会议安排』时使用;" + + "内部用本地时区自动把时间范围算成今天 00:00 到次日 00:00,转成毫秒时间戳," + + "查询主日历(primary)下今天的全部日程。只读,不会创建或修改任何日程。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + // No required flags: "today" is fully derived from the local clock. + }, + Tips: []string{ + `dws calendar +today`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Today's [00:00, next-day 00:00) window in local time, as epoch millis. + startOfToday, startOfTomorrow := calendarDayRange(0) + toolArgs := map[string]any{ + "startTime": startOfToday.UnixMilli(), + "endTime": startOfTomorrow.UnixMilli(), + "calendarId": "primary", + } + + // Project each event to {title, start, end, location, eventId} via the + // shared calendarProjectEvents (same output as +tomorrow/+week) instead + // of dumping the raw 17-field event objects. + data, err := rt.CallMCPData("calendar", "list_calendar_events", toolArgs) + if err != nil { + return err + } + return rt.Output(map[string]any{"events": calendarProjectEvents(data)}) + }, +} + +func init() { + shortcut.Register(Today) +} diff --git a/internal/shortcut/smart/todo_done.go b/internal/shortcut/smart/todo_done.go new file mode 100644 index 00000000..bc9037ba --- /dev/null +++ b/internal/shortcut/smart/todo_done.go @@ -0,0 +1,166 @@ +// 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" +) + +// TodoDone: mark one of MY todos complete by matching a keyword in its title. +// +// Steps: +// +// 1. list my todos via get_user_todos_in_current_org (pageNum / pageSize as +// strings, mirroring helpers.todo list); +// +// 2. scan result.todoCards[] and keep the ones whose subject contains --task; +// none → "没找到匹配待办", many → list candidates (subject + taskId) so the +// caller can be more specific, exactly one → take its taskId; +// +// 3. mark it done via update_todo_done_status (taskId + isDone="true", +// mirroring `dws todo task done`). +// +// dws todo +todo-done --task 周报 +var TodoDone = shortcut.Shortcut{ + Service: "todo", + Command: "+todo-done", + Product: "todo", + Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)", + Intent: "当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;" + + "内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」," + + "匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "task", Type: shortcut.FlagString, Desc: "待办标题关键词", Required: true}, + }, + Tips: []string{`dws todo +todo-done --task 周报`}, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("task")) + if keyword == "" { + return apperrors.NewValidation("请用 --task 提供待办标题关键词") + } + + // Step 1 — list my todos. pageNum/pageSize are strings and roleTypes + // defaults to executor, mirroring helpers.buildListTodoTaskArgs. + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{ + "pageNum": "1", + "pageSize": "50", + "roleTypes": []string{"executor"}, + }) + if err != nil { + return err + } + + // Step 2 — match by subject substring. + matches := shortcutTodoMatch(data, keyword) + switch { + case len(matches) == 0: + return apperrors.NewValidation(fmt.Sprintf("没找到匹配待办:标题里没有 %q 的待办。", keyword)) + case len(matches) > 1: + return apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 条待办,请用更精确的关键词,或用 `dws todo task done --task-id` 指定:%s", + keyword, len(matches), strings.Join(shortcutTodoLabels(matches), ";"))) + } + + // Step 3 — mark it done. taskId + isDone mirror helpers `todo task done` + // (update_todo_done_status, isDone passed as a string). + return rt.CallMCP("update_todo_done_status", map[string]any{ + "taskId": matches[0].taskID, + "isDone": "true", + }) + }, +} + +// shortcutTodoCard is the minimal identity we need from a todoCards entry. +type shortcutTodoCard struct { + taskID string + subject string +} + +// shortcutTodoMatch walks a get_user_todos_in_current_org response +// ({"result": {"todoCards": [ {subject, taskId, ...} ]}}) and returns the cards +// whose subject contains keyword (case-insensitive). +func shortcutTodoMatch(data map[string]any, keyword string) []shortcutTodoCard { + cards := shortcutTodoCards(data) + kw := strings.ToLower(keyword) + var out []shortcutTodoCard + for _, m := range cards { + subject, _ := m["subject"].(string) + if subject == "" { + continue + } + if !strings.Contains(strings.ToLower(subject), kw) { + continue + } + id := shortcutTodoTaskID(m) + if id == "" { + continue + } + out = append(out, shortcutTodoCard{taskID: id, subject: subject}) + } + return out +} + +// shortcutTodoCards pulls the todoCards list out of the response, probing the +// common container shape {"result": {"todoCards": [...]}} first, then a bare +// top-level todoCards as a fallback. +func shortcutTodoCards(data map[string]any) []map[string]any { + if r, ok := data["result"].(map[string]any); ok { + if arr, ok := r["todoCards"].([]any); ok { + return shortcutTodoToMaps(arr) + } + } + if arr, ok := data["todoCards"].([]any); ok { + return shortcutTodoToMaps(arr) + } + return nil +} + +func shortcutTodoToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// shortcutTodoTaskID reads a card's taskId, tolerating both string and numeric +// JSON encodings. +func shortcutTodoTaskID(m map[string]any) string { + switch v := m["taskId"].(type) { + case string: + return v + case float64: + return fmt.Sprintf("%.0f", v) + } + return "" +} + +func shortcutTodoLabels(cards []shortcutTodoCard) []string { + out := make([]string, 0, len(cards)) + for _, c := range cards { + out = append(out, fmt.Sprintf("%s(taskId=%s)", c.subject, c.taskID)) + } + return out +} + +func init() { + shortcut.Register(TodoDone) +} diff --git a/internal/shortcut/smart/tomorrow.go b/internal/shortcut/smart/tomorrow.go new file mode 100644 index 00000000..aee91847 --- /dev/null +++ b/internal/shortcut/smart/tomorrow.go @@ -0,0 +1,67 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Tomorrow: list the current user's calendar events for tomorrow, with the day +// boundaries computed automatically — the mirror of +today shifted one day +// forward. Handy for "what's on my plate tomorrow" without hand-crafting a time +// range. +// +// It resolves "tomorrow" from the machine's local clock: startTime = tomorrow +// 00:00, endTime = day-after 00:00 (local timezone), both converted to epoch +// milliseconds — the int64 shape list_calendar_events expects. calendarId +// defaults to the primary calendar. Events are projected to +// {title,start,end,location,eventId}, identical to +today / +week. +// +// dws calendar +tomorrow +var Tomorrow = shortcut.Shortcut{ + Service: "calendar", + Command: "+tomorrow", + Product: "calendar", + Description: "列出我明天的日程(自动计算明天的起止时间,无需手动填时间范围)", + Intent: "当你想快速看看『我明天有哪些日程/会议安排』、提前准备时使用;" + + "内部用本地时区自动把时间范围算成明天 00:00 到后天 00:00,转成毫秒时间戳," + + "查询主日历(primary)下明天的全部日程,并投影出标题、开始时间、结束时间、地点、eventId。只读,不会创建或修改任何日程。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + // No required flags: "tomorrow" is fully derived from the local clock. + }, + Tips: []string{ + `dws calendar +tomorrow`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Tomorrow's [00:00, day-after 00:00) window in local time, as epoch millis. + startOfTomorrow, startOfDayafter := calendarDayRange(1) + toolArgs := map[string]any{ + "startTime": startOfTomorrow.UnixMilli(), + "endTime": startOfDayafter.UnixMilli(), + "calendarId": "primary", + } + + // Same {title,start,end,location,eventId} projection as +today/+week. + data, err := rt.CallMCPData("calendar", "list_calendar_events", toolArgs) + if err != nil { + return err + } + return rt.Output(map[string]any{"events": calendarProjectEvents(data)}) + }, +} + +func init() { + shortcut.Register(Tomorrow) +} diff --git a/internal/shortcut/smart/transcript.go b/internal/shortcut/smart/transcript.go new file mode 100644 index 00000000..d189393c --- /dev/null +++ b/internal/shortcut/smart/transcript.go @@ -0,0 +1,94 @@ +// 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 ( + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Transcript: fetch the verbatim transcript (逐字稿 / 语音转写原文) of MY most +// recent minutes (听记) in one step. +// +// Steps: +// 1. list my minutes via list_by_keyword_and_time_range (belongingConditionId +// = "created"), optionally filtered by --keyword; +// 2. pick the newest entry (largest create time, falling back to the first +// item) and read its taskUuid — reusing latestMinutesTaskUUID; +// 3. print that minute's verbatim transcript via get_minutes_transcription +// (taskUuid + direction, mirroring helpers.minutesGetTranscriptionCmd). +// +// If the list is empty it reports "暂无妙记" instead of failing obscurely. +// +// dws minutes +transcript +// dws minutes +transcript --keyword 周会 +// dws minutes +transcript --direction 1 +var Transcript = shortcut.Shortcut{ + Service: "minutes", + Command: "+transcript", + Product: "minutes", + Description: "取我最新一条妙记(听记)的逐字稿(语音转写原文)", + Intent: "当你想直接读回自己最近一次会议听记的逐字稿(完整语音转写原文),又不想先翻列表、复制 taskUuid 再单独查转写时使用;" + + "内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的逐字记录(每条含发言人、文本、时间戳)。" + + "可用 --direction 控制排序:0=正序(时间递增,默认),1=倒序。这是只读操作,不会修改任何听记;" + + "若你名下没有任何听记则提示「暂无妙记」。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false}, + {Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false}, + }, + Tips: []string{ + `dws minutes +transcript`, + `dws minutes +transcript --keyword 周会`, + `dws minutes +transcript --direction 1`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — list my minutes (newest first). belongingConditionId / + // maxResults / keyword mirror helpers.callListByKeywordRange. + listArgs := map[string]any{ + "belongingConditionId": "created", + "maxResults": float64(20), + } + if kw := rt.Str("keyword"); kw != "" { + listArgs["keyword"] = kw + } + data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs) + if err != nil { + return err + } + + // Step 2 — locate the newest minute's taskUuid (reuses helper from + // latest_minutes.go). + taskUUID := latestMinutesTaskUUID(data) + if taskUUID == "" { + return apperrors.NewValidation("暂无妙记") + } + + // Step 3 — print its verbatim transcript. taskUuid + direction mirror + // helpers.minutesGetTranscriptionCmd (get_minutes_transcription). + // direction defaults to "0" (正序) when not provided. + direction := rt.Str("direction") + if direction == "" { + direction = "0" + } + return rt.CallMCP("get_minutes_transcription", map[string]any{ + "taskUuid": taskUUID, + "direction": direction, + }) + }, +} + +func init() { + shortcut.Register(Transcript) +} diff --git a/internal/shortcut/smart/unread_chats.go b/internal/shortcut/smart/unread_chats.go new file mode 100644 index 00000000..27861e20 --- /dev/null +++ b/internal/shortcut/smart/unread_chats.go @@ -0,0 +1,207 @@ +// 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 ( + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// UnreadChats: list MY conversations that currently have unread messages in one +// step. +// +// It calls unread_message_conversation_list on the chat server — the exact tool +// and parameter names used by helpers.chatMessageListUnreadConversationsCmd. The +// helper passes count (int) only when > 0 and excludeMuted (bool) only when +// true, so both are optional; this shortcut mirrors that: --count controls how +// many conversations to return (0 / unset uses the server default) and +// --exclude-muted drops chats you have muted. +// +// The returned conversations are then defensively projected down to +// {name, unread, conversationId} (multiple candidate keys per field). When the +// response carries no recognisable conversation list we fall back to printing +// the raw payload via rt.Output so it still honours --format/--jq/--fields. +// +// Read-only: it only lists and reshapes, it never marks anything read/unread. +// +// dws chat +unread-chats +// dws chat +unread-chats --count 20 +// dws chat +unread-chats --exclude-muted +var UnreadChats = shortcut.Shortcut{ + Service: "chat", + Command: "+unread-chats", + Product: "chat", + Description: "列出我有未读消息的会话(投影会话名/未读数/会话ID)", + Intent: "当你想快速看清自己当前有哪些会话还有未读消息、方便逐个处理时使用;" + + "内部调用未读会话列表接口,可用 --count 控制返回的会话条数(不传则用服务端默认值)," + + "用 --exclude-muted 排除你已设置免打扰的会话;再在本地把每个会话投影成会话名、未读数和会话 ID 三个关键字段。" + + "这是纯只读操作,只做列表与本地投影,不会把任何会话标记为已读或未读;若没有未读会话则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数(可选,不传则使用服务端默认值)", Required: false}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "是否排除已设置免打扰的会话(可选,默认 false)", Required: false}, + }, + Tips: []string{ + `dws chat +unread-chats`, + `dws chat +unread-chats --count 20`, + `dws chat +unread-chats --exclude-muted`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Build params exactly like chatMessageListUnreadConversationsCmd: count is + // only sent when > 0, excludeMuted only when true. + toolArgs := map[string]any{} + if count := rt.Int("count"); count > 0 { + toolArgs["count"] = count + } + if rt.Bool("exclude-muted") { + toolArgs["excludeMuted"] = true + } + + data, err := rt.CallMCPData("chat", "unread_message_conversation_list", toolArgs) + if err != nil { + return err + } + + // Project each conversation; fall back to the raw payload when we cannot + // locate a recognisable conversation list. + items := unreadChatItems(data) + if len(items) == 0 { + return rt.Output(data) + } + results := make([]map[string]any, 0, len(items)) + for _, m := range items { + row := map[string]any{ + "name": unreadChatName(m), + "conversationId": unreadChatConversationID(m), + } + // unread_message_conversation_list does not return a per-conversation + // unread count (presence in the list already implies unread>0), so + // only surface "unread" when the gateway actually provides it — avoid + // emitting a noisy null on every row. + if u := unreadChatUnread(m); u != nil { + row["unread"] = u + } + results = append(results, row) + } + return rt.Output(map[string]any{"conversations": results}) + }, +} + +// unreadChatItems locates the conversation list inside an +// unread_message_conversation_list response, probing common container keys at +// the top level and nested under "result"/"data". Returns nil when no list is +// found. +func unreadChatItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + keys := []string{"list", "conversations", "conversationList", "unreadConversations", "items", "data", "records", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return unreadChatToMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, k2 := range []string{"list", "conversations", "conversationList", "unreadConversations", "items", "data", "records"} { + if arr, ok := inner[k2].([]any); ok { + return unreadChatToMaps(arr) + } + } + } + } + return nil +} + +func unreadChatToMaps(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// unreadChatName reads a conversation's display title, preferring a readable +// name and falling back to a nested conversation object. +func unreadChatName(m map[string]any) any { + for _, key := range []string{"conversationTitle", "title", "conversationName", "groupName", "chatTitle", "name", "showName"} { + if v := unreadChatString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"conversation", "chat", "group"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"title", "name", "conversationTitle", "groupName", "showName"} { + if v := unreadChatString(nested[k2]); v != "" { + return v + } + } + } + } + return nil +} + +// unreadChatUnread reads a conversation's unread message count, returning the +// raw value under whichever candidate key is present. +func unreadChatUnread(m map[string]any) any { + for _, key := range []string{"unreadCount", "unread", "unReadCount", "unreadNum", "redPoint", "count"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +// unreadChatConversationID reads the conversation id, tolerating the common id +// keys the gateway may use. +func unreadChatConversationID(m map[string]any) any { + for _, key := range []string{"openConversationId", "conversationId", "conversation_id", "cid", "openCid", "chatId"} { + if v := unreadChatString(m[key]); v != "" { + return v + } + } + for _, key := range []string{"conversation", "chat"} { + if nested, ok := m[key].(map[string]any); ok { + for _, k2 := range []string{"openConversationId", "conversationId", "cid", "openCid"} { + if v := unreadChatString(nested[k2]); v != "" { + return v + } + } + } + } + return nil +} + +// unreadChatString coerces a scalar JSON value to a trimmed string, returning "" +// for nil / non-scalar / empty values. +func unreadChatString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func init() { + shortcut.Register(UnreadChats) +} diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go new file mode 100644 index 00000000..6cb725f5 --- /dev/null +++ b/internal/shortcut/smart/unread_mail.go @@ -0,0 +1,104 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// UnreadMail: list the current user's unread emails as a compact projection. +// +// Steps: +// +// 1. resolve the mailbox address — use --email when given, otherwise pick the +// current user's first bound mailbox via list_user_mailboxes (reusing +// searchMailFirstMailbox from search_mail.go); +// +// 2. search that mailbox via search_emails with the KQL filter isRead:false, +// matching the isRead field documented in helpers.messageSearch; size is +// passed as a string, defaulting to "20" like the helper's sizeVal; +// +// 3. in Go, project each returned message to {subject, from, date, messageId} +// via the shared searchMail* helpers and print the list with rt.Output so it +// honours --format/--jq/--fields. +// +// Read-only: it only lists and projects, never mutating any mail. +// +// dws mail +unread-mail +// dws mail +unread-mail --email user@company.com +var UnreadMail = shortcut.Shortcut{ + Service: "mail", + Command: "+unread-mail", + Product: "mail", + Description: "列出未读邮件并投影列表(主题/发件人/时间/messageId)", + Intent: "当你想快速看自己邮箱里有哪些未读邮件、并只看一份精简清单(主题、发件人、时间、邮件 messageId)而不想翻完整正文时使用;" + + "内部先确定要查的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——" + + "再用 KQL 过滤条件 isRead:false 搜索未读邮件," + + "最后在本地把每封邮件投影成 {subject, from, date, messageId} 打印出来,可配合 --format/--jq/--fields。" + + "这是纯只读操作,只做搜索与本地投影,不会把邮件标记为已读,也不会修改、发送或删除任何邮件;若没有未读邮件则返回空列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + }, + Tips: []string{ + `dws mail +unread-mail`, + `dws mail +unread-mail --email user@company.com`, + `dws mail +unread-mail --size 50`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Step 1 — resolve the mailbox address. + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + + // Step 2 — search unread mail. email/query/size mirror the search_emails + // call in helpers.messageSearch; the isRead:false KQL filter matches the + // isRead field documented there; size is passed as a string. + size := rt.Str("size") + if size == "" { + size = "20" + } + data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + "email": email, + "query": "isRead:false", + "size": size, + }) + if err != nil { + return err + } + + // Step 3 — project each message to a compact record (shared helpers). + messages := searchMailMessages(data) + out := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + out = append(out, map[string]any{ + "subject": searchMailFirstString(m, "subject", "title", "topic"), + "from": searchMailFrom(m), + "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), + "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), + }) + } + return rt.Output(map[string]any{"messages": out, "email": email}) + }, +} + +func init() { + shortcut.Register(UnreadMail) +} diff --git a/internal/shortcut/smart/week.go b/internal/shortcut/smart/week.go new file mode 100644 index 00000000..a76485c6 --- /dev/null +++ b/internal/shortcut/smart/week.go @@ -0,0 +1,85 @@ +// 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 ( + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Week: list the current user's calendar events for the current week (Monday as +// the first day of the week), with the week boundaries computed automatically +// so the caller never has to hand-craft ISO/millisecond time ranges. +// +// It resolves "this week" from the machine's local clock: startTime = this +// Monday 00:00, endTime = next Monday 00:00 (local timezone), both converted to +// epoch milliseconds — exactly the int64 shape the `list_calendar_events` tool +// expects at its helper call site (parseISOTimeToMillis -> millis). calendarId +// defaults to the primary calendar. The response events are defensively parsed +// and projected (title / start / end / eventId) via rt.Output so they honour +// --format/--jq/--fields. Replaces the manual +// `calendar event list --start ... --end ...` where you must format two +// timezone-aware timestamps by hand. Read-only. +// +// dws calendar +week +var Week = shortcut.Shortcut{ + Service: "calendar", + Command: "+week", + Product: "calendar", + Description: "列出我本周的日程(自动按周一为周首计算本周起止时间,无需手动填时间范围)", + Intent: "当你想快速看看『我本周有哪些日程/会议安排』时使用;" + + "内部用本地时区、以周一为一周开始,自动把时间范围算成本周一 00:00 到下周一 00:00,转成毫秒时间戳," + + "查询主日历(primary)下本周的全部日程,并投影出标题、开始时间、结束时间、eventId。只读,不会创建或修改任何日程。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + // No required flags: "this week" is fully derived from the local clock. + }, + Tips: []string{ + `dws calendar +week`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Compute this week's [Monday 00:00, next-Monday 00:00) window in the + // local timezone, as epoch milliseconds. list_calendar_events expects + // startTime/endTime as int64 millis (helper builds them via UnixMilli / + // parseISOTimeToMillis). Monday is treated as the first day of the week. + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // Go's Weekday(): Sunday=0..Saturday=6. Offset to the most recent Monday: + // Monday->0, Tuesday->1, ... Sunday->6. + offset := (int(startOfDay.Weekday()) + 6) % 7 + startOfWeek := startOfDay.AddDate(0, 0, -offset) + startOfNextWeek := startOfWeek.AddDate(0, 0, 7) + + // Params copied verbatim from the helper's list_calendar_events call site: + // startTime/endTime (int64 millis) + calendarId (defaults to primary). + params := map[string]any{ + "calendarId": "primary", + "startTime": startOfWeek.UnixMilli(), + "endTime": startOfNextWeek.UnixMilli(), + } + + data, err := rt.CallMCPData("calendar", "list_calendar_events", params) + if err != nil { + return err + } + + // Same {title,start,end,location,eventId} projection as +today/+tomorrow. + return rt.Output(map[string]any{"events": calendarProjectEvents(data)}) + }, +} + +func init() { + shortcut.Register(Week) +} diff --git a/internal/shortcut/smart/whoami.go b/internal/shortcut/smart/whoami.go new file mode 100644 index 00000000..f141a7f4 --- /dev/null +++ b/internal/shortcut/smart/whoami.go @@ -0,0 +1,136 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Whoami: print the CURRENT user's own profile as a clean projection — the +// "who am I" every agent needs before resolving other people. Unlike the 1:1 +// +get-self (which dumps the verbose raw {result:[{orgEmployeeModel:{…}}]}), +// this projects to {name, userId, mobile, dept, org, email}. +// +// It calls the zero-arg get_current_user_profile (always "me", no --name), +// mirroring the resolution +my-attendance already relies on. +// +// dws contact +me +var Whoami = shortcut.Shortcut{ + Service: "contact", + Command: "+me", + Product: "contact", + Description: "查看我自己的通讯录资料(姓名/userId/手机/部门/组织,干净投影)", + Intent: "当你(或 AI agent)需要知道「我是谁」——我自己的 userId、姓名、所在部门、组织、手机号,用于后续按名解析他人前先确定自己身份、或填充发起人信息时使用;" + + "内部调用零参数的 get_current_user_profile(永远是「我」,无需传姓名),再把冗长的原始资料投影成 {name,userId,mobile,dept,org,email} 几个关键字段。" + + "这是纯只读操作,不修改任何资料。", + Risk: shortcut.RiskRead, + Tips: []string{ + `dws contact +me`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + if err != nil { + return err + } + return rt.Output(whoamiProject(data)) + }, +} + +// whoamiProject digs the current user's core fields out of a +// get_current_user_profile response, tolerating the {result:[{orgEmployeeModel}]} +// envelope the gateway uses as well as flatter shapes. +func whoamiProject(data map[string]any) map[string]any { + m := whoamiEmployeeModel(data) + out := map[string]any{} + if v := whoamiStr(m, "orgUserName", "name", "userName", "nick"); v != "" { + out["name"] = v + } + if v := whoamiStr(m, "userId", "userid", "staffId"); v != "" { + out["userId"] = v + } + if v := whoamiStr(m, "orgUserMobile", "mobile", "stateMobile"); v != "" { + out["mobile"] = v + } + if v := whoamiStr(m, "orgAuthEmail", "email", "orgEmail"); v != "" { + out["email"] = v + } + if v := whoamiStr(m, "orgName", "corpName"); v != "" { + out["org"] = v + } + if dept := whoamiFirstDept(m); dept != "" { + out["dept"] = dept + } + if len(out) == 0 { + // Unrecognised shape — fall back to the raw payload rather than an empty + // object. + return data + } + return out +} + +// whoamiEmployeeModel locates the org-employee record inside the profile +// response, probing the common containers. +func whoamiEmployeeModel(data map[string]any) map[string]any { + if data == nil { + return map[string]any{} + } + // {result:[{orgEmployeeModel:{…}}]} + if arr, ok := data["result"].([]any); ok && len(arr) > 0 { + if first, ok := arr[0].(map[string]any); ok { + if em, ok := first["orgEmployeeModel"].(map[string]any); ok { + return em + } + return first + } + } + // {orgEmployeeModel:{…}} or {result:{…}} or the object itself. + if em, ok := data["orgEmployeeModel"].(map[string]any); ok { + return em + } + if res, ok := data["result"].(map[string]any); ok { + if em, ok := res["orgEmployeeModel"].(map[string]any); ok { + return em + } + return res + } + return data +} + +// whoamiFirstDept reads the first department name from the depts list. +func whoamiFirstDept(m map[string]any) string { + if arr, ok := m["depts"].([]any); ok { + for _, d := range arr { + if dm, ok := d.(map[string]any); ok { + if v := whoamiStr(dm, "deptName", "name", "departmentName"); v != "" { + return v + } + } + } + } + return "" +} + +// whoamiStr returns the first non-empty string among the candidate keys. +func whoamiStr(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(Whoami) +} diff --git a/internal/shortcut/smart/wiki_new_doc.go b/internal/shortcut/smart/wiki_new_doc.go new file mode 100644 index 00000000..b0266ad2 --- /dev/null +++ b/internal/shortcut/smart/wiki_new_doc.go @@ -0,0 +1,209 @@ +// 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" +) + +// WikiNewDoc: create a new document node inside a knowledge space identified BY +// NAME, in one command. +// +// Steps: search knowledge spaces by the given name (search_wikiSpaces) → resolve +// exactly one space to its workspaceId (0 or >1 matches → a clear disambiguation +// error, never a guess) → create an online document node under that space's root +// (create_file on the doc MCP server, mirroring `dws wiki node create`). +// Replaces the manual dance of `dws wiki space search --query ` (copy the +// workspaceId) → `dws wiki node create --workspace --name `. +// +// dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要" +var WikiNewDoc = shortcut.Shortcut{ + Service: "wiki", + Command: "+wiki-new-doc", + Product: "wiki", + Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", + Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" + + "内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" + + "如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" + + "这会真实创建一个新的文档节点。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true}, + }, + Tips: []string{ + `dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + spaceName := strings.TrimSpace(rt.Str("space")) + title := strings.TrimSpace(rt.Str("title")) + if spaceName == "" { + return apperrors.NewValidation("--space 不能为空") + } + if title == "" { + return apperrors.NewValidation("--title 不能为空") + } + + // Step 1 — search knowledge spaces by name. keyword param mirrors the + // helper's `wiki space search` call site (search_wikiSpaces). + data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{ + "keyword": spaceName, + }) + if err != nil { + return err + } + + // Step 2 — resolve exactly one space to its workspaceId; refuse to guess + // on 0 or multiple exact-name matches. + workspaceID, err := wikiNewDocResolveSpaceID(data, spaceName) + if err != nil { + return err + } + + // Step 3 — create the document node under the space root. workspaceId / + // name / type params copied verbatim from the helper's `wiki node create` + // call site (create_file lives on the doc MCP server, so route there + // explicitly via CallMCPData rather than this shortcut's own product; + // adoc = 在线文档). + created, err := rt.CallMCPData("doc", "create_file", map[string]any{ + "workspaceId": workspaceID, + "name": title, + "type": "adoc", + }) + if err != nil { + return err + } + // Surface the created doc (id/url in the response) instead of returning + // silently — previously the caller got no confirmation of the new doc. + return rt.Output(map[string]any{ + "created": true, + "space": spaceName, + "title": title, + "result": created, + }) + }, +} + +// wikiSpaceCandidate is the minimal {id, name} a space needs after searching. +type wikiSpaceCandidate struct { + id string + name string +} + +// wikiNewDocResolveSpaceID pulls the single space whose name matches spaceName +// out of a search_wikiSpaces response and returns its workspaceId. It errors +// clearly when nothing matches or when the name is ambiguous, never guessing. +func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) { + spaces := wikiNewDocExtractSpaces(data) + if len(spaces) == 0 { + return "", apperrors.NewValidation(fmt.Sprintf( + "没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName)) + } + + // Prefer exact (case-insensitive) name matches to disambiguate a keyword + // search that may return partial hits. + var exact []wikiSpaceCandidate + for _, s := range spaces { + if strings.EqualFold(strings.TrimSpace(s.name), spaceName) { + exact = append(exact, s) + } + } + + candidates := exact + if len(candidates) == 0 { + // No exact match: fall back to whatever the search returned so we can + // give a precise disambiguation message instead of a blind pick. + candidates = spaces + } + + switch { + case len(candidates) == 1: + if candidates[0].id == "" { + return "", apperrors.NewValidation(fmt.Sprintf( + "匹配到知识库 %q,但返回结果里没有可用的 workspaceId。", candidates[0].name)) + } + return candidates[0].id, nil + default: + return "", apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 个知识库:%s。请用更精确的空间名再试。", + spaceName, len(candidates), strings.Join(wikiNewDocLabels(candidates), "、"))) + } +} + +// wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces +// response may take into a list of {id, name} candidates. The gateway wraps the +// list under one of several common container keys, so probe them defensively. +func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate { + if data == nil { + return nil + } + for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} { + switch v := data[key].(type) { + case []any: + return wikiNewDocToCandidates(v) + case map[string]any: + for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} { + if arr, ok := v[k2].([]any); ok { + return wikiNewDocToCandidates(arr) + } + } + } + } + return nil +} + +func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate { + out := make([]wikiSpaceCandidate, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]any) + if !ok { + continue + } + id := "" + for _, k := range []string{"workspaceId", "spaceId", "id"} { + if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" { + id = s + break + } + } + name := "" + for _, k := range []string{"name", "spaceName", "title"} { + if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" { + name = s + break + } + } + if id == "" && name == "" { + continue + } + out = append(out, wikiSpaceCandidate{id: id, name: name}) + } + return out +} + +func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string { + out := make([]string, 0, len(spaces)) + for _, s := range spaces { + out = append(out, fmt.Sprintf("%s(%s)", s.name, s.id)) + } + return out +} + +func init() { + shortcut.Register(WikiNewDoc) +}