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:
1 parent
cd652bf9ab
commit
4e4705c673
70 files changed
+9778
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user