feat(shortcut): smart orchestration layer (68 shortcuts)

Multi-step / intelligent shortcuts under internal/shortcut/smart: name→ID
resolvers (user/base/table/dept/space), name-based actions (chat +dm/+broadcast/
+group-members, todo +assign, calendar +book with rollback/+free/+invite/
+suggest-time), time & self intelligence (calendar +today/+tomorrow/+week/
+next-event/+my-free and +conflicts/+free-slots scheduling intelligence),
convenience reads (contact +me, oa +pending/+done-approvals, todo +due-today/
+related-tasks, mail +recent-mail/+find-mail-user, attendance +this-month) and
aggregation (minutes +detail, aitable +record-share-links). Projections hardened
against real DingTalk responses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DennisandClaude Opus 4.8 committed 2026-07-10 10:04:10 +08:00
1 parent cd652bf9ab
commit 4e4705c673
70 files changed
+9778

No files matched your search

+75
View File
@@ -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)
}
+280
View File
@@ -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)
}
+65
View File
@@ -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 <id>`.
//
// 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)
}
+120
View File
@@ -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)
}
+291
View File
@@ -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)
}
+155
View File
@@ -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)
}
+106
View File
@@ -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)
}
+130
View File
@@ -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 <id>`.
//
// 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)
}
@@ -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
}
+123
View File
@@ -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)
}
+208
View File
@@ -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 <openconversation_id> --time "2025-03-01 00:00:00"
// dws chat +chat-messages --user <userId> --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 <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
`dws chat +chat-messages --group <openconversation_id> --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)
}
+152
View File
@@ -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)
}
+93
View File
@@ -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)
}
+194
View File
@@ -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 <deptId>`.
//
// 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 <deptId>。",
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 <red>…</red>
// 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 "<deptName>(<deptId>)" 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)
}
+67
View File
@@ -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 <id>`.
//
// 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)
}
+75
View File
@@ -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/<DOC_UUID>" --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)
}
+109
View File
@@ -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)
}
+132
View File
@@ -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)
}
+146
View File
@@ -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)
}
+159
View File
@@ -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)
}
+158
View File
@@ -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)
}
+71
View File
@@ -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)
}
+187
View File
@@ -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)
}
+143
View File
@@ -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<to<=24")
}
workStart := day.Add(time.Duration(fromHour) * time.Hour)
workEnd := day.Add(time.Duration(toHour) * time.Hour)
// List the day's events (a little wider than the work window so events
// spanning the edges are captured).
data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{
"startTime": day.UnixMilli(),
"endTime": day.AddDate(0, 0, 1).UnixMilli(),
"calendarId": "primary",
})
if err != nil {
return err
}
// Collect and clip busy intervals to the work window.
type interval struct{ start, end time.Time }
var busy []interval
for _, e := range shortcutNextEventList(data) {
s, ok := shortcutNextEventStart(e)
if !ok {
continue
}
en, ok := conflictsEndTime(e)
if !ok {
continue
}
if en.After(workStart) && s.Before(workEnd) {
if s.Before(workStart) {
s = workStart
}
if en.After(workEnd) {
en = workEnd
}
busy = append(busy, interval{s, en})
}
}
sort.Slice(busy, func(i, j int) bool { return busy[i].start.Before(busy[j].start) })
// Sweep the work window, emitting the gaps between merged busy blocks.
free := make([]map[string]any, 0)
cursor := workStart
for _, b := range busy {
if b.start.After(cursor) {
free = append(free, freeSlotEntry(cursor, b.start))
}
if b.end.After(cursor) {
cursor = b.end
}
}
if cursor.Before(workEnd) {
free = append(free, freeSlotEntry(cursor, workEnd))
}
return rt.Output(map[string]any{
"date": day.Format("2006-01-02"),
"window": fmt.Sprintf("%02d:00-%02d:00", fromHour, toHour),
"slotCount": len(free),
"freeSlots": free,
"allBusy": len(free) == 0,
})
},
}
// freeSlotEntry renders a free window with its duration in minutes.
func freeSlotEntry(start, end time.Time) map[string]any {
return map[string]any{
"start": start.Format("15:04"),
"end": end.Format("15:04"),
"durationMin": int(end.Sub(start).Minutes()),
}
}
func init() {
shortcut.Register(FreeSlots)
}
+139
View File
@@ -0,0 +1,139 @@
// 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"
)
// FreeBusy: check whether a person is busy or free in a time window, by NAME.
//
// Steps: resolve the person's name → unique userId → query their busy/free
// status over the given range. Replaces `contact +search-user` (copy userId) →
// `calendar busy search --users <id> --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)
}
+147
View File
@@ -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 <openConversationId>`.
//
// Note: the group lookup uses `search_groups` (im server, keyword search over
// group NAMES) — NOT `search_common_groups`, which searches by member
// nicknames and cannot locate a group by its title.
//
// dws chat +group-members --group 项目冲刺
var GroupMembers = shortcut.Shortcut{
Service: "chat",
Command: "+group-members",
Product: "chat",
Description: "按群名列出群成员(自动搜群解析 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 <openConversationId> 指定群。",
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)
}
+82
View File
@@ -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)
}
+165
View File
@@ -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)
}
+155
View File
@@ -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)
}
+55
View File
@@ -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 <id>`.
//
// 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)
}
+128
View File
@@ -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 <taskUuid>
// dws minutes +detail --id <taskUuid> --artifacts summary,todos
// dws minutes +detail --id <taskUuid> --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 <taskUuid>`,
`dws minutes +detail --id <taskUuid> --artifacts summary,todos`,
`dws minutes +detail --id <taskUuid> --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)
}
+108
View File
@@ -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)
}
+129
View File
@@ -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 <id> --start <today> --end <today>`.
// 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)
}
+110
View File
@@ -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)
}
+171
View File
@@ -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)
}
+216
View File
@@ -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)
}
+178
View File
@@ -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)
}
+155
View File
@@ -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 <id>` →
// copy deptId → `contact dept get-info --dept <deptId>`.
//
// 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)
}
+165
View File
@@ -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)
}
@@ -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)
}
+188
View File
@@ -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)
}
@@ -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)
}
+208
View File
@@ -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)
}
+85
View File
@@ -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)
}
+135
View File
@@ -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 <taskUuid> --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 <taskUuid> --pair "张三=>张三丰"`,
`dws minutes +replace-batch --id <taskUuid> --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)
}
+220
View File
@@ -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)
}
+85
View File
@@ -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)
}
+85
View File
@@ -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
}
+148
View File
@@ -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)
}
+176
View File
@@ -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 <red>…</red>
// 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 <red>/</red> (and similar simple) highlight
// tags the DingTalk search gateway injects around matched keywords.
func stripHighlightTags(s string) string {
for _, tag := range []string{"<red>", "</red>", "<b>", "</b>", "<em>", "</em>"} {
s = strings.ReplaceAll(s, tag, "")
}
return s
}
func init() {
shortcut.Register(ResolveDept)
}
+149
View File
@@ -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)
}
+183
View File
@@ -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)
}
+91
View File
@@ -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)
}
+217
View File
@@ -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)
}
+237
View File
@@ -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 <openConversationId> --query "changefree"
// dws chat +search-msg --group <openConversationId> --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 <openConversationId> --query "changefree"`,
`dws chat +search-msg --group <openConversationId> --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)
}
+148
View File
@@ -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 <openConversationId>`.
//
// Note: the group lookup uses `search_groups` (im server, keyword search over
// group NAMES) — NOT `search_common_groups`, which searches by member nicknames
// and cannot locate a group by its title.
//
// dws chat +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 <openConversationId> 指定群。",
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)
}
+86
View File
@@ -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)
}
+141
View File
@@ -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)
}
+86
View File
@@ -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 <id>` →
// copy deptId → `contact dept list-members --depts <deptId>`.
//
// 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)
}
@@ -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)
}
+181
View File
@@ -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 <openconversationId> --topic-id <topicId>
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 <openconversationId> --topic-id <topicId>`,
`dws chat +thread-replies --group <openconversationId> --topic-id <topicId> --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)
}
+70
View File
@@ -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)
}
+166
View File
@@ -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)
}
+67
View File
@@ -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)
}
+94
View File
@@ -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)
}
+207
View File
@@ -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)
}
+104
View File
@@ -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)
}
+85
View File
@@ -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)
}
+136
View File
@@ -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)
}
+209
View File
@@ -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 <name>` (copy the
// workspaceId) → `dws wiki node create --workspace <id> --name <title>`.
//
// 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)
}