From cd652bf9ab3684e4fb2560d28e210dd495ac3fe5 Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 10 Jul 2026 10:04:10 +0800 Subject: [PATCH] feat(shortcut): 298 one-to-one MCP tool wrappers across 16 services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative 1:1 shortcuts (dws +) wrapping DingTalk MCP tools with named flags, required/enum validation, risk confirmation and a natural-language Intent; list/read commands add clean output projection. Scoped to tools the helper command layer does NOT already expose, plus a handful that add projection — the redundant re-wraps were pruned. Tool names/params are taken verbatim from internal/helpers ground truth. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/shortcut/aitable/aitable.go | 2593 +++++++++++++++++++ internal/shortcut/attendance/attendance.go | 1553 +++++++++++ internal/shortcut/calendar/calendar.go | 733 ++++++ internal/shortcut/chat/chat.go | 71 + internal/shortcut/chat/chat_bot.go | 179 ++ internal/shortcut/chat/chat_conversation.go | 749 ++++++ internal/shortcut/chat/chat_group.go | 913 +++++++ internal/shortcut/chat/chat_message.go | 1007 +++++++ internal/shortcut/contact/contact.go | 502 ++++ internal/shortcut/devapp/devapp.go | 1140 ++++++++ internal/shortcut/ding/ding.go | 117 + internal/shortcut/doc/doc.go | 642 +++++ internal/shortcut/drive/drive.go | 535 ++++ internal/shortcut/mail/mail.go | 695 +++++ internal/shortcut/minutes/minutes.go | 290 +++ internal/shortcut/oa/oa.go | 444 ++++ internal/shortcut/report/report.go | 237 ++ internal/shortcut/sheet/sheet.go | 193 ++ internal/shortcut/todo/todo.go | 429 +++ internal/shortcut/wiki/wiki.go | 312 +++ 20 files changed, 13334 insertions(+) create mode 100644 internal/shortcut/aitable/aitable.go create mode 100644 internal/shortcut/attendance/attendance.go create mode 100644 internal/shortcut/calendar/calendar.go create mode 100644 internal/shortcut/chat/chat.go create mode 100644 internal/shortcut/chat/chat_bot.go create mode 100644 internal/shortcut/chat/chat_conversation.go create mode 100644 internal/shortcut/chat/chat_group.go create mode 100644 internal/shortcut/chat/chat_message.go create mode 100644 internal/shortcut/contact/contact.go create mode 100644 internal/shortcut/devapp/devapp.go create mode 100644 internal/shortcut/ding/ding.go create mode 100644 internal/shortcut/doc/doc.go create mode 100644 internal/shortcut/drive/drive.go create mode 100644 internal/shortcut/mail/mail.go create mode 100644 internal/shortcut/minutes/minutes.go create mode 100644 internal/shortcut/oa/oa.go create mode 100644 internal/shortcut/report/report.go create mode 100644 internal/shortcut/sheet/sheet.go create mode 100644 internal/shortcut/todo/todo.go create mode 100644 internal/shortcut/wiki/wiki.go diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go new file mode 100644 index 00000000..1ce20aab --- /dev/null +++ b/internal/shortcut/aitable/aitable.go @@ -0,0 +1,2593 @@ +// 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 aitable provides declarative shortcuts for the DingTalk AI 表格 +// (aitable) service: Base / table / field / record / view / form / dashboard / +// chart / workflow / advanced-permission / section management. Each shortcut maps +// 1:1 onto an MCP tool declared in internal/helpers/aitable.go. +// +// Tool routing: most tools live on the "aitable" MCP server; a subset of helper +// tools live on the "aitable-helper" server. Each shortcut sets Product to the +// server that owns its tool so rt.CallMCP dispatches correctly. +package aitable + +import ( + "encoding/json" + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// serverMain is the primary aitable MCP server id. +const serverMain = "aitable" + +// serverHelper is the aitable-helper MCP server id (hosts a subset of tools). +const serverHelper = "aitable-helper" + +// parseJSONAny parses an arbitrary JSON string (object or array) into any. +func parseJSONAny(flag, s string) (any, error) { + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, fmt.Errorf("--%s JSON 解析失败: %w", flag, err) + } + return v, nil +} + +// parseJSONObject parses a JSON string into a map[string]any, erroring on arrays. +func parseJSONObject(flag, s string) (map[string]any, error) { + v, err := parseJSONAny(flag, s) + if err != nil { + return nil, err + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("--%s 必须是 JSON 对象,got %T", flag, v) + } + return m, nil +} + +// ───────────────────────────────────────────────────────────── +// base: Base 管理(server: aitable) +// ───────────────────────────────────────────────────────────── + +// BaseList 获取 AI 表格列表(list_bases)。 +var BaseList = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-list", + Product: serverMain, + Description: "获取当前用户可访问的 AI 表格 Base 列表(最近访问,支持游标分页)", + Intent: "当你不知道具体 baseId、想先浏览自己最近用过或可访问的 AI 表格清单以便定位目标时使用;支持游标分页,返回 Base 列表及其 baseId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量,默认 10,最大 10"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,首次不传"}, + }, + Tips: []string{`dws aitable +base-list`, `dws aitable +base-list --limit 5 --cursor NEXT`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData(serverMain, "list_bases", params) + if err != nil { + return err + } + bases := baseListProject(data) + return rt.Output(map[string]any{"count": len(bases), "bases": bases}) + }, +} + +// baseListProject reshapes list_bases / search_bases responses into a clean +// {baseId, baseName} list — clean output projection. Both the list +// container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func baseListProject(data map[string]any) []map[string]any { + raw := baseListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := baseListFirst(m, "baseId", "base_id", "id"); ok { + row["baseId"] = v + } + if v, ok := baseListFirst(m, "baseName", "base_name", "name", "title"); ok { + row["baseName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// baseListResolveList locates the list payload inside the response, tolerating a +// bare top-level array container or nesting one level under a common envelope. +func baseListResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"bases", "result", "data", "list", "items", "records"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"bases", "list", "items", "result", "data", "records"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// baseListFirst returns the first present candidate key's value. +func baseListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// BaseSearch 按名称关键词搜索 AI 表格(search_bases)。 +var BaseSearch = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-search", + Product: serverMain, + Description: "按名称关键词搜索 AI 表格 Base", + Intent: "当你知道某个 AI 表格的名字或部分关键词、想直接定位到它并拿到 baseId 时使用;输入名称关键词,返回匹配的 Base 列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "Base 名称关键词", Required: true}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,首次不传"}, + }, + Tips: []string{`dws aitable +base-search --query "项目管理"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"query": rt.Str("query")} + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData(serverMain, "search_bases", params) + if err != nil { + return err + } + bases := baseListProject(data) + return rt.Output(map[string]any{"count": len(bases), "bases": bases}) + }, +} + +// BaseGet 获取 AI 表格信息(get_base)。 +var BaseGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-get", + Product: serverMain, + Description: "获取指定 Base 的目录信息(tables / dashboards summary)", + Intent: "当你已有 baseId、需要了解这个表格里有哪些数据表和仪表盘(拿到 tableId/dashboardId)以便进一步操作时使用;返回 Base 的目录结构概要。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +base-get --base-id BASE_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_base", map[string]any{"baseId": rt.Str("base-id")}) + }, +} + +// BaseGetPrimaryDocID 获取记录主键文档 ID(get_base_primary_doc_id)。 +var BaseGetPrimaryDocID = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-get-primary-doc-id", + Product: serverMain, + Description: "根据 baseId/tableId/recordId 获取主键文档的 dentryUuid", + Intent: "当某条记录的主键列是文档类型、你需要拿到该主键文档的 dentryUuid 以便打开或引用该文档时使用;输入 base/table/record,返回 dentryUuid。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "记录 ID", Required: true}, + }, + Tips: []string{`dws aitable +base-get-primary-doc-id --base-id B --table-id T --record-id R`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_base_primary_doc_id", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "recordId": rt.Str("record-id"), + }) + }, +} + +// BaseCreate 创建 AI 表格(create_base)。 +// BaseUpdate 更新 AI 表格(update_base)。 +var BaseUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-update", + Product: serverMain, + Description: "更新 Base 名称(可选备注)", + Intent: "当你要给已有 AI 表格改名或修改备注说明时使用;会实际修改指定 Base 的名称/描述。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新名称,1-50 字符", Required: true}, + {Name: "desc", Type: shortcut.FlagString, Desc: "备注文本(可选)"}, + }, + Tips: []string{`dws aitable +base-update --base-id BASE_ID --name "新名称"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "newBaseName": rt.Str("name"), + } + if rt.Changed("desc") { + params["description"] = rt.Str("desc") + } + return rt.CallMCP("update_base", params) + }, +} + +// BaseDelete 删除 AI 表格(delete_base)。 +var BaseDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-delete", + Product: serverMain, + Description: "删除指定 Base(不可逆)", + Intent: "当你确认要彻底删除某个 AI 表格时使用;会不可逆地删除整个 Base 及其所有数据表和记录,操作前务必核对 baseId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "待删除 Base ID", Required: true}, + {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, + }, + Tips: []string{`dws aitable +base-delete --base-id BASE_ID --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"baseId": rt.Str("base-id")} + if rt.Changed("reason") { + params["reason"] = rt.Str("reason") + } + return rt.CallMCP("delete_base", params) + }, +} + +// BaseCopy 复制 AI 表格(copy_base)。 +var BaseCopy = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-copy", + Product: serverMain, + Description: "复制 AI 表格到指定目录(可仅复制结构)", + Intent: "当你想基于现有表格快速复刻一份(如做模板或备份,可选仅复制结构不含数据)时使用;会在目标文件夹实际创建一个副本 Base。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "源 Base ID", Required: true}, + {Name: "target-folder-id", Type: shortcut.FlagString, Desc: "目标文件夹 dentryUuid", Required: true}, + {Name: "only-struct", Type: shortcut.FlagBool, Desc: "仅复制结构(不含数据),默认 false"}, + }, + Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("copy_base", map[string]any{ + "baseId": rt.Str("base-id"), + "targetFolderId": rt.Str("target-folder-id"), + "onlyCopyMeta": rt.Bool("only-struct"), + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// table: 数据表管理(server: aitable) +// ───────────────────────────────────────────────────────────── + +// TableGet 获取数据表(get_tables)。 +var TableGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+table-get", + Product: serverMain, + Description: "批量获取指定数据表的表级信息、字段目录与视图目录", + Intent: "当你已进入某个 Base、需要了解其中某些数据表有哪些字段(拿 fieldId)、有哪些视图(拿 viewId)以便读写数据时使用;批量返回表信息、字段目录和视图目录。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-ids", Type: shortcut.FlagStringSlice, Desc: "Table ID 列表,逗号分隔,单次最多 10 个(可选)"}, + }, + Tips: []string{`dws aitable +table-get --base-id BASE_ID`, `dws aitable +table-get --base-id B --table-ids tbl1,tbl2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"baseId": rt.Str("base-id")} + if rt.Changed("table-ids") { + params["tableIds"] = rt.StrSlice("table-ids") + } + return rt.CallMCP("get_tables", params) + }, +} + +// TableCreate 创建数据表(create_table)。 +// TableUpdate 更新数据表(update_table)。 +var TableUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+table-update", + Product: serverMain, + Description: "更新数据表名称 / 备注 / 行命名规则", + Intent: "当你要给数据表改名、改备注,或调整行的命名规则(如按 task/project 命名)时使用;会实际更新表级属性。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新表名(可选)"}, + {Name: "description", Type: shortcut.FlagString, Desc: "备注说明(可选)"}, + {Name: "record-name-key", Type: shortcut.FlagString, Desc: "行命名规则枚举键,如 task/project(可选)"}, + }, + Tips: []string{`dws aitable +table-update --base-id B --table-id T --name "新表名"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("name") { + params["newTableName"] = rt.Str("name") + } + if rt.Changed("description") { + params["description"] = rt.Str("description") + } + if rt.Changed("record-name-key") { + params["recordNameKey"] = rt.Str("record-name-key") + } + return rt.CallMCP("update_table", params) + }, +} + +// TableDelete 删除数据表(delete_table)。 +var TableDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+table-delete", + Product: serverMain, + Description: "删除指定数据表(不可逆)", + Intent: "当你确认要删除整张数据表(连同其所有记录和视图)时使用;不可逆,操作前请核对 tableId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "待删除 Table ID", Required: true}, + {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, + }, + Tips: []string{`dws aitable +table-delete --base-id B --table-id T --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("reason") { + params["reason"] = rt.Str("reason") + } + return rt.CallMCP("delete_table", params) + }, +} + +// ───────────────────────────────────────────────────────────── +// field: 字段管理(server: aitable) +// ───────────────────────────────────────────────────────────── + +// FieldGet 获取字段详情(get_fields)。 +var FieldGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+field-get", + Product: serverMain, + Description: "批量获取字段详情(含类型相关完整配置)", + Intent: "当你需要查看字段的完整类型配置(如单选选项、关联表设置、AI 配置)以便正确写入数据或改配置时使用;批量返回字段详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "字段 ID 列表,逗号分隔,单次最多 10 个(可选)"}, + }, + Tips: []string{`dws aitable +field-get --base-id B --table-id T`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("field-ids") { + params["fieldIds"] = rt.StrSlice("field-ids") + } + return rt.CallMCP("get_fields", params) + }, +} + +// FieldCreate 创建字段(create_fields)。 +// FieldUpdate 更新字段(update_field)。 +var FieldUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+field-update", + Product: serverMain, + Description: "更新字段名称 / 配置 / AI 配置(类型不可改)", + Intent: "当你要改字段名,或调整字段配置/AI 配置(注意字段类型本身不可改)时使用;会实际更新指定字段。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "Field ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新字段名(可选)"}, + {Name: "config", Type: shortcut.FlagString, Desc: "字段配置 JSON(可选)"}, + {Name: "ai-config", Type: shortcut.FlagString, Desc: "AI 配置 JSON(可选)"}, + }, + Tips: []string{`dws aitable +field-update --base-id B --table-id T --field-id F --name "新名"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "fieldId": rt.Str("field-id"), + } + if rt.Changed("name") { + params["newFieldName"] = rt.Str("name") + } + if rt.Changed("config") { + cfg, err := parseJSONObject("config", rt.Str("config")) + if err != nil { + return err + } + params["config"] = cfg + } + if rt.Changed("ai-config") { + cfg, err := parseJSONObject("ai-config", rt.Str("ai-config")) + if err != nil { + return err + } + params["aiConfig"] = cfg + } + return rt.CallMCP("update_field", params) + }, +} + +// FieldSearchOptions 搜索单选/多选字段选项(search_field_options)。 +// FieldDelete 删除字段(delete_field)。 +var FieldDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+field-delete", + Product: serverMain, + Description: "删除指定字段(不可逆)", + Intent: "当你确认要删除某个字段(连同该列在所有记录里的数据)时使用;不可逆,操作前请核对 fieldId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "待删除字段 ID", Required: true}, + }, + Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_field", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "fieldId": rt.Str("field-id"), + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// record: 记录管理(server: aitable / aitable-helper) +// ───────────────────────────────────────────────────────────── + +// RecordQuery 获取行记录(query_records)。 +var RecordQuery = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-query", + Product: serverMain, + Description: "查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页)", + Intent: "当你要读取表格里的行数据——按 recordId 精确取、按结构化条件筛选、按关键词全文搜索或分页遍历时使用;返回匹配记录及其单元格值。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "记录 ID 列表,单次最多 100(可选)"}, + {Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "返回字段 ID 列表(可选)"}, + {Name: "filters", Type: shortcut.FlagString, Desc: "结构化过滤条件 JSON(可选)"}, + {Name: "sort", Type: shortcut.FlagString, Desc: "排序条件 JSON 数组(可选)"}, + {Name: "query", Type: shortcut.FlagString, Desc: "全文关键词(可选)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "单次最大记录数,默认 100(可选)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标(可选)"}, + }, + Tips: []string{`dws aitable +record-query --base-id B --table-id T --query "关键词" --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("record-ids") { + params["recordIds"] = rt.StrSlice("record-ids") + } + if rt.Changed("field-ids") { + params["fieldIds"] = rt.StrSlice("field-ids") + } + if rt.Changed("filters") { + f, err := parseJSONAny("filters", rt.Str("filters")) + if err != nil { + return err + } + params["filters"] = f + } + if rt.Changed("sort") { + s, err := parseJSONAny("sort", rt.Str("sort")) + if err != nil { + return err + } + params["sort"] = s + } + if rt.Changed("query") { + params["keyword"] = rt.Str("query") + } + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + return rt.CallMCP("query_records", params) + }, +} + +// RecordCreate 新增记录(create_records)。 +// RecordUpdate 更新记录(update_records)。 +var RecordUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-update", + Product: serverMain, + Description: "批量更新记录(每条含 recordId 与 cells),单次最多 100 条", + Intent: "当你要批量修改已有行的字段值(如把一批任务标为已完成,每条须带 recordId)时使用;会实际覆盖对应单元格,单次最多 100 条。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,如 '[{\"recordId\":\"rec\",\"cells\":{...}}]'", Required: true}, + }, + Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + recs, err := parseJSONAny("records", rt.Str("records")) + if err != nil { + return err + } + return rt.CallMCP("update_records", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "records": recs, + }) + }, +} + +// RecordDelete 删除行记录(delete_records)。 +var RecordDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-delete", + Product: serverMain, + Description: "批量删除记录(不可逆),单次最多 100 条", + Intent: "当你确认要批量删除若干行记录时使用;不可逆,按 recordId 列表删除,单次最多 100 条。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔", Required: true}, + }, + Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2 --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_records", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "recordIds": rt.StrSlice("record-ids"), + }) + }, +} + +// RecordQueryEmpty 查询空行(query_empty_records,server: aitable-helper)。 +var RecordQueryEmpty = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-query-empty", + Product: serverHelper, + Description: "扫描并过滤出完全没填用户字段的空行", + Intent: "当你想清理表格、需要先找出那些所有用户字段都为空的空行时使用;扫描并返回空行列表,支持分页预算。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "单次扫描预算,范围 [1,100](可选)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标(可选)"}, + }, + Tips: []string{`dws aitable +record-query-empty --base-id B --table-id T`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + return rt.CallMCP("query_empty_records", params) + }, +} + +// RecordHistoryList 查询记录变更历史(query_record_history,server: aitable-helper)。 +var RecordHistoryList = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-history-list", + Product: serverHelper, + Description: "按 recordId 查询单条记录的变更历史", + Intent: "当你要追溯某条记录曾被谁在何时改过哪些字段时使用;按 recordId 分页返回该行的变更历史。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "记录 ID", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Desc: "分页偏移量,默认 0(可选)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量,默认 20,最大 50(可选)"}, + }, + Tips: []string{`dws aitable +record-history-list --base-id B --table-id T --record-id R`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "recordId": rt.Str("record-id"), + } + if rt.Changed("offset") { + params["offset"] = rt.Int("offset") + } + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + return rt.CallMCP("query_record_history", params) + }, +} + +// RecordShareURL 批量获取记录分享链接(get_record_share_url,server: aitable-helper)。 +var RecordShareURL = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-share-url", + Product: serverHelper, + Description: "按 recordId 批量获取记录分享链接,单次最多 20 条", + Intent: "当你要把某几条记录以链接形式分享给他人(可带视图上下文)时使用;按 recordId 批量返回分享链接,单次最多 20 条。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", 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,生成带视图上下文的链接(可选)"}, + }, + Tips: []string{`dws aitable +record-share-url --base-id B --table-id T --record-ids rec1,rec2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "recordIds": rt.StrSlice("record-ids"), + } + if rt.Changed("view-id") { + params["viewId"] = rt.Str("view-id") + } + return rt.CallMCP("get_record_share_url", params) + }, +} + +// RecordUpsert 批量创建或更新记录(record_upsert,server: aitable-helper)。 +var RecordUpsert = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-upsert", + Product: serverHelper, + Description: "按 records 是否带 recordId 自动拆分 create / update", + Intent: "当你有一批数据、其中部分是新增部分是更新、不想自己区分时使用;按记录是否带 recordId 自动拆分为创建或更新并实际写入,单次最多 100 条。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,单次最多 100 条", Required: true}, + }, + Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + recs, err := parseJSONAny("records", rt.Str("records")) + if err != nil { + return err + } + return rt.CallMCP("record_upsert", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "records": recs, + }) + }, +} + +// RecordPrimaryDocGet 查询记录主键文档(get_primary_doc,server: aitable-helper)。 +var RecordPrimaryDocGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-primary-doc-get", + Product: serverHelper, + Description: "查询记录关联的主键文档 nodeId", + Intent: "当某记录已关联主键文档、你需要拿到该文档的 nodeId 以便打开或编辑时使用;返回主键文档 nodeId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "记录 ID", Required: true}, + }, + Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_primary_doc", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "recordId": rt.Str("record-id"), + }) + }, +} + +// RecordPrimaryDocCreate 为记录创建主键文档(create_primary_doc,server: aitable-helper)。 +var RecordPrimaryDocCreate = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-primary-doc-create", + Product: serverHelper, + Description: "为记录创建主键文档(幂等),fieldId 须为 primaryDoc 类型", + Intent: "当某记录的主键文档列还没有对应文档、你要为它新建一个时使用;幂等操作,fieldId 须为 primaryDoc 类型,会实际生成主键文档。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "主键字段 ID(primaryDoc 类型)", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "记录 ID", Required: true}, + }, + Tips: []string{`dws aitable +record-primary-doc-create --base-id B --table-id T --field-id F --record-id R`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("create_primary_doc", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "fieldId": rt.Str("field-id"), + "recordId": rt.Str("record-id"), + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// template / attachment(server: aitable) +// ───────────────────────────────────────────────────────────── + +// TemplateSearch 搜索模板(search_templates)。 +var TemplateSearch = shortcut.Shortcut{ + Service: "aitable", + Command: "+template-search", + Product: serverMain, + Description: "按名称关键词搜索 AI 表格模板", + Intent: "当你要新建表格并想套用现成模板、需要先按关键词找模板(不传关键词则返回热门)时使用;返回模板列表及其模板 ID。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词(可选,不传返回热门)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量,默认 10,最大 30(可选)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标(可选)"}, + }, + Tips: []string{`dws aitable +template-search --query "项目管理"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("query") { + params["query"] = rt.Str("query") + } + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData(serverMain, "search_templates", params) + if err != nil { + return err + } + templates := templateSearchProject(data) + return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + }, +} + +// templateSearchProject reshapes the raw search_templates response into a clean +// {templateId, templateName} list — clean output projection. Both +// the list container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func templateSearchProject(data map[string]any) []map[string]any { + raw := templateSearchResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := templateSearchFirst(m, "templateId", "template_id", "id"); ok { + row["templateId"] = v + } + if v, ok := templateSearchFirst(m, "templateName", "template_name", "name", "title"); ok { + row["templateName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// templateSearchResolveList locates the list payload inside the response, +// tolerating a bare top-level array container or nesting one level under a +// common envelope key. +func templateSearchResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"templates", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"templates", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// templateSearchFirst returns the first present candidate key's value. +func templateSearchFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// AttachmentUpload 准备附件上传(prepare_attachment_upload)。 +var AttachmentUpload = shortcut.Shortcut{ + Service: "aitable", + Command: "+attachment-upload", + Product: serverMain, + Description: "为 attachment 字段申请 OSS 直传地址(uploadUrl / fileToken)", + Intent: "当你要往 attachment(附件)字段上传文件、需要先申请 OSS 直传地址时使用;返回 uploadUrl 和 fileToken,供你直传文件后再写入记录。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "file-name", Type: shortcut.FlagString, Desc: "文件名(含扩展名)", Required: true}, + {Name: "size", Type: shortcut.FlagInt, Desc: "文件大小(字节),须 > 0", Required: true}, + {Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME type,如 image/png(可选)"}, + }, + Tips: []string{`dws aitable +attachment-upload --base-id B --file-name report.xlsx --size 204800`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "fileName": rt.Str("file-name"), + "size": rt.Int("size"), + } + if rt.Changed("mime-type") { + params["mimeType"] = rt.Str("mime-type") + } + return rt.CallMCP("prepare_attachment_upload", params) + }, +} + +// ───────────────────────────────────────────────────────────── +// view: 视图管理(server: aitable / aitable-helper) +// ───────────────────────────────────────────────────────────── + +// ViewGet 获取视图详情(get_views)。 +var ViewGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-get", + Product: serverMain, + Description: "获取视图完整信息(列顺序、筛选、排序、分组等)", + Intent: "当你要了解某个视图当前的列顺序、筛选、排序、分组等完整配置以便复用或修改时使用;批量返回视图详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-ids", Type: shortcut.FlagStringSlice, Desc: "View ID 列表,单次最多 10 个(可选)"}, + }, + Tips: []string{`dws aitable +view-get --base-id B --table-id T`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("view-ids") { + params["viewIds"] = rt.StrSlice("view-ids") + } + data, err := rt.CallMCPData(serverMain, "get_views", params) + if err != nil { + return err + } + views := viewGetProject(data) + return rt.Output(map[string]any{"count": len(views), "views": views}) + }, +} + +// viewGetProject reshapes the raw get_views response into a clean +// {viewId, viewName, viewType} list — clean output projection. Both +// the list container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func viewGetProject(data map[string]any) []map[string]any { + raw := viewGetResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := viewGetFirst(m, "viewId", "view_id", "id"); ok { + row["viewId"] = v + } + if v, ok := viewGetFirst(m, "viewName", "view_name", "name", "title"); ok { + row["viewName"] = v + } + if v, ok := viewGetFirst(m, "viewType", "view_type", "type"); ok { + row["viewType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// viewGetResolveList locates the list payload inside the response, tolerating a +// bare top-level array container or nesting one level under a common envelope +// key. +func viewGetResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"views", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"views", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// viewGetFirst returns the first present candidate key's value. +func viewGetFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ViewCreate 创建视图(create_view)。 +// ViewUpdate 更新视图(update_view)。 +var ViewUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-update", + Product: serverMain, + Description: "更新视图名称 / 描述 / 配置(visibleFieldIds、filter、sort、group 等)", + Intent: "当你要调整视图的展示——改可见列、筛选条件、排序、分组或改名时使用;会实际更新视图配置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新视图名(可选)"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "视图描述 JSON(可选)"}, + {Name: "config", Type: shortcut.FlagString, Desc: "视图配置更新项 JSON(可选)"}, + }, + Tips: []string{`dws aitable +view-update --base-id B --table-id T --view-id V --config '{"visibleFieldIds":["fld1"]}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + } + if rt.Changed("name") { + params["newViewName"] = rt.Str("name") + } + if rt.Changed("desc") { + d, err := parseJSONObject("desc", rt.Str("desc")) + if err != nil { + return err + } + params["viewDescription"] = d + } + if rt.Changed("config") { + c, err := parseJSONObject("config", rt.Str("config")) + if err != nil { + return err + } + params["config"] = c + } + return rt.CallMCP("update_view", params) + }, +} + +// ViewDuplicate 复制视图(duplicate_view,server: aitable-helper)。 +var ViewDuplicate = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-duplicate", + Product: serverHelper, + Description: "复制视图,生成配置相同的新视图", + Intent: "当你想基于某个已配置好的视图快速再造一个相同配置的视图时使用;会实际复制生成新视图。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "源 View ID", Required: true}, + {Name: "new-name", Type: shortcut.FlagString, Desc: "新视图名(可选)"}, + }, + Tips: []string{`dws aitable +view-duplicate --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "sourceViewId": rt.Str("view-id"), + } + if rt.Changed("new-name") { + params["newViewName"] = rt.Str("new-name") + } + return rt.CallMCP("duplicate_view", params) + }, +} + +// ViewDelete 删除视图(delete_view)。 +var ViewDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-delete", + Product: serverMain, + Description: "删除指定视图(不可逆)", + Intent: "当你确认要删除某个视图时使用;不可逆,仅删除该展示视图、不影响底层记录数据。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "待删除 View ID", Required: true}, + }, + Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// ViewGetLock 获取视图锁定状态(get_view_lock_status,server: aitable-helper)。 +var ViewGetLock = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-get-lock", + Product: serverHelper, + Description: "获取视图锁定状态", + Intent: "当你想确认某视图是否已被锁定(以防他人误改其配置)时使用;返回该视图的锁定状态。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + }, + Tips: []string{`dws aitable +view-get-lock --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_view_lock_status", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// ViewLock 锁定/解锁视图(lock_or_unlock_view,server: aitable-helper)。 +var ViewLock = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-lock", + Product: serverHelper, + Description: "锁定视图(默认)或解锁(--off)", + Intent: "当你要锁定视图以防止他人修改其配置、或反过来解锁时使用;默认锁定,加 --off 解锁,会实际改变视图锁定状态。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "传入则解锁(unlock),默认锁定(lock)"}, + }, + Tips: []string{`dws aitable +view-lock --base-id B --table-id T --view-id V`, `dws aitable +view-lock --base-id B --table-id T --view-id V --off`}, + Execute: func(rt *shortcut.RuntimeContext) error { + action := "lock" + if rt.Bool("off") { + action = "unlock" + } + return rt.CallMCP("lock_or_unlock_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "action": action, + }) + }, +} + +// ViewGetFrozenCols 获取视图冻结列数(get_frozen_columns_of_view,server: aitable-helper)。 +var ViewGetFrozenCols = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-get-frozen-cols", + Product: serverHelper, + Description: "获取视图当前冻结的左侧列数", + Intent: "当你想知道某视图当前冻结了左侧几列时使用;返回冻结列数。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + }, + Tips: []string{`dws aitable +view-get-frozen-cols --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_frozen_columns_of_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// ViewSetFrozenCols 设置视图冻结列数(set_frozen_columns_of_view,server: aitable-helper)。 +var ViewSetFrozenCols = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-set-frozen-cols", + Product: serverHelper, + Description: "设置视图冻结列数(0 表示取消冻结)", + Intent: "当你要冻结视图左侧若干列、以便横向滚动时保持这些列可见(0 表示取消冻结)时使用;会实际修改视图冻结列数。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "count", Type: shortcut.FlagInt, Desc: "冻结列数,须 >= 0", Required: true}, + }, + Tips: []string{`dws aitable +view-set-frozen-cols --base-id B --table-id T --view-id V --count 1`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_frozen_columns_of_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "count": rt.Int("count"), + }) + }, +} + +// ViewGetRowHeight 获取视图行高(get_cell_height_of_view,server: aitable-helper)。 +var ViewGetRowHeight = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-get-row-height", + Product: serverHelper, + Description: "获取视图单元格行高(像素)", + Intent: "当你想知道某视图当前的行高档位时使用;返回单元格行高的像素值。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + }, + Tips: []string{`dws aitable +view-get-row-height --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_cell_height_of_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// ViewSetRowHeight 设置视图行高(set_cell_height_of_view,server: aitable-helper)。 +var ViewSetRowHeight = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-set-row-height", + Product: serverHelper, + Description: "设置视图单元格行高(像素,合法档位 32/56/88/128)", + Intent: "当你要调整视图行高、让内容显示更宽松或更紧凑(合法档位 32/56/88/128)时使用;会实际修改视图行高。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "cell-height", Type: shortcut.FlagInt, Desc: "单元格高度(像素),须 > 0", Required: true}, + }, + Tips: []string{`dws aitable +view-set-row-height --base-id B --table-id T --view-id V --cell-height 56`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_cell_height_of_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "cellHeight": rt.Int("cell-height"), + }) + }, +} + +// ViewSetFillColorRule 更新视图数据高亮规则(set_view_fill_color_rule,server: aitable)。 +var ViewSetFillColorRule = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-set-fill-color-rule", + Product: serverMain, + Description: "全量覆盖 Grid 视图的条件填色规则(传 '[]' 清空)", + Intent: "当你要为 Grid 视图设置条件填色(按规则给符合条件的行/单元格上色),或传空数组清空所有填色规则时使用;会全量覆盖该视图的填色规则。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "json", Type: shortcut.FlagString, Desc: "conditionalFormats JSON 数组", Required: true}, + }, + Tips: []string{`dws aitable +view-set-fill-color-rule --base-id B --table-id T --view-id V --json '[]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + arr, err := parseJSONAny("json", rt.Str("json")) + if err != nil { + return err + } + return rt.CallMCP("set_view_fill_color_rule", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "conditionalFormats": arr, + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// form: 表单管理(server: aitable / aitable-helper) +// ───────────────────────────────────────────────────────────── + +// FormCreate 创建表单视图(create_view, viewType=FormDesigner,server: aitable)。 +// FormList 列出表单视图(list_form_views,server: aitable-helper)。 +var FormList = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-list", + Product: serverHelper, + Description: "列出指定数据表下的所有表单视图", + Intent: "当你要查看某数据表下已有哪些收集表单时使用;返回该表的全部表单视图列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + }, + Tips: []string{`dws aitable +form-list --base-id B --table-id T`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData(serverHelper, "list_form_views", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + }) + if err != nil { + return err + } + forms := formListProject(data) + return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + }, +} + +// formListProject reshapes the raw list_form_views response into a clean form +// view list ({viewId, viewName, viewType}) — clean output projection. +// Both the list container and the per-item field names are probed +// defensively across candidate keys, so an empty/unknown shape yields an empty +// list rather than a crash or fabricated data. +func formListProject(data map[string]any) []map[string]any { + raw := formListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := formListFirst(m, "viewId", "view_id", "id"); ok { + row["viewId"] = v + } + if v, ok := formListFirst(m, "viewName", "view_name", "name", "title"); ok { + row["viewName"] = v + } + if v, ok := formListFirst(m, "viewType", "view_type", "type"); ok { + row["viewType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// formListResolveList locates the list payload inside the response, tolerating a +// bare top-level array container or nesting one level under a common envelope. +func formListResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"forms", "views", "formViews", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"forms", "views", "formViews", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// formListFirst returns the first present candidate key's value. +func formListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// FormDelete 删除表单(delete_form_view,server: aitable-helper)。 +var FormDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-delete", + Product: serverHelper, + Description: "删除指定表单视图(不可逆)", + Intent: "当你确认要删除某个收集表单时使用;不可逆,仅删除该表单视图。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, + }, + Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_form_view", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// FormUpdate 更新表单配置(update_form_info,server: aitable-helper)。 +var FormUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-update", + Product: serverHelper, + Description: "更新表单标题 / 描述", + Intent: "当你要修改表单对外展示的标题或说明文案时使用;会实际更新表单的标题/描述。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "表单标题(可选)"}, + {Name: "description", Type: shortcut.FlagString, Desc: "表单描述(可选)"}, + }, + Tips: []string{`dws aitable +form-update --base-id B --table-id T --view-id V --title "员工信息收集"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + } + if rt.Changed("title") { + params["title"] = rt.Str("title") + } + if rt.Changed("description") { + params["description"] = rt.Str("description") + } + return rt.CallMCP("update_form_info", params) + }, +} + +// FormFieldList 列出表单字段(list_form_fields,server: aitable-helper)。 +var FormFieldList = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-field-list", + Product: serverHelper, + Description: "列出表单视图当前可见的字段及其配置", + Intent: "当你要查看某表单当前放出了哪些字段供填写及其是否必填等配置时使用;返回表单可见字段列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, + }, + Tips: []string{`dws aitable +form-field-list --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_form_fields", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// FormFieldUpdate 更新表单字段(update_form_field,server: aitable-helper)。 +var FormFieldUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-field-update", + Product: serverHelper, + Description: "更新表单字段的必填状态或描述", + Intent: "当你要把表单里某个字段设为必填/非必填,或补充其填写说明时使用;会实际更新该表单字段的配置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "Field ID", Required: true}, + {Name: "required", Type: shortcut.FlagBool, Desc: "是否必填(可选)"}, + {Name: "field-description", Type: shortcut.FlagString, Desc: "字段描述(可选)"}, + }, + Tips: []string{`dws aitable +form-field-update --base-id B --table-id T --view-id V --field-id F --required true`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "fieldId": rt.Str("field-id"), + } + if rt.Changed("required") { + params["required"] = rt.Bool("required") + } + if rt.Changed("field-description") { + params["fieldDescription"] = rt.Str("field-description") + } + return rt.CallMCP("update_form_field", params) + }, +} + +// FormFieldHide 切换表单字段隐藏(update_form_field_hidden,server: aitable-helper)。 +var FormFieldHide = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-field-hide", + Product: serverHelper, + Description: "切换表单字段的隐藏/显示状态", + Intent: "当你要在表单里隐藏或重新显示某个字段时使用;会实际切换该字段在表单中的显示状态。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "Field ID", Required: true}, + {Name: "hidden", Type: shortcut.FlagBool, Desc: "true 隐藏 / false 显示", Required: true}, + }, + Tips: []string{`dws aitable +form-field-hide --base-id B --table-id T --view-id V --field-id F --hidden true`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_form_field_hidden", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "fieldId": rt.Str("field-id"), + "hidden": rt.Bool("hidden"), + }) + }, +} + +// FormShareGet 获取表单分享配置(get_share_form_config,server: aitable-helper)。 +var FormShareGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-share-get", + Product: serverHelper, + Description: "读取视图当前的分享表单配置", + Intent: "当你要查看某视图的表单分享是否已开启及其分享配置时使用;返回当前的分享表单配置。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + }, + Tips: []string{`dws aitable +form-share-get --base-id B --table-id T --view-id V`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_share_form_config", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + }) + }, +} + +// FormShareUpdate 开启/关闭分享表单(update_share_form,server: aitable-helper)。 +var FormShareUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+form-share-update", + Product: serverHelper, + Description: "开启或关闭指定视图的分享表单", + Intent: "当你要对外开启或关闭某表单的分享(生成或停用可对外填写的链接)时使用;会实际改变表单分享开关。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID", Required: true}, + {Name: "enabled", Type: shortcut.FlagString, Desc: "开启/关闭分享", Required: true, Enum: []string{"true", "false"}}, + }, + Tips: []string{`dws aitable +form-share-update --base-id B --table-id T --view-id V --enabled true`}, + Execute: func(rt *shortcut.RuntimeContext) error { + // helper 透传 enabled 的字符串值,保持一致。 + return rt.CallMCP("update_share_form", map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + "viewId": rt.Str("view-id"), + "enabled": rt.Str("enabled"), + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// workflow: 自动化工作流(server: aitable-helper) +// ───────────────────────────────────────────────────────────── + +// WorkflowEnable 启用工作流(enable_workflow)。 +var WorkflowEnable = shortcut.Shortcut{ + Service: "aitable", + Command: "+workflow-enable", + Product: serverHelper, + Description: "启用指定 Base 中的自动化工作流", + Intent: "当你要启用某个已配置好的自动化工作流、让它重新生效时使用;会实际把该 workflow 置为启用。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true}, + }, + Tips: []string{`dws aitable +workflow-enable --base-id B --workflow-id W`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("enable_workflow", map[string]any{ + "baseId": rt.Str("base-id"), + "workflowId": rt.Str("workflow-id"), + }) + }, +} + +// WorkflowDisable 禁用工作流(disable_workflow)。 +var WorkflowDisable = shortcut.Shortcut{ + Service: "aitable", + Command: "+workflow-disable", + Product: serverHelper, + Description: "禁用指定 Base 中的自动化工作流(影响业务自动化)", + Intent: "当你要停用某个自动化工作流时使用;会实际停用该 workflow,可能中断依赖它的业务自动化,请谨慎确认。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true}, + }, + Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("disable_workflow", map[string]any{ + "baseId": rt.Str("base-id"), + "workflowId": rt.Str("workflow-id"), + }) + }, +} + +// WorkflowGet 获取工作流详情(get_workflow)。 +var WorkflowGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+workflow-get", + Product: serverHelper, + Description: "获取单个自动化工作流的详细信息", + Intent: "当你要查看某个自动化工作流的触发条件与执行动作等详情时使用;返回单个 workflow 的完整信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true}, + }, + Tips: []string{`dws aitable +workflow-get --base-id B --workflow-id W`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_workflow", map[string]any{ + "baseId": rt.Str("base-id"), + "workflowId": rt.Str("workflow-id"), + }) + }, +} + +// WorkflowList 列出工作流(list_workflows)。 +var WorkflowList = shortcut.Shortcut{ + Service: "aitable", + Command: "+workflow-list", + Product: serverHelper, + Description: "列出指定 Base 中的自动化工作流(分页)", + Intent: "当你想了解某 Base 里配置了哪些自动化工作流及其开关状态时使用;分页返回 workflow 列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量,默认 20,最大 100(可选)"}, + {Name: "offset", Type: shortcut.FlagInt, Desc: "分页偏移量,默认 0(可选)"}, + }, + Tips: []string{`dws aitable +workflow-list --base-id B`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"baseId": rt.Str("base-id")} + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + if rt.Changed("offset") { + params["offset"] = rt.Int("offset") + } + data, err := rt.CallMCPData(serverHelper, "list_workflows", params) + if err != nil { + return err + } + workflows := workflowListProject(data) + return rt.Output(map[string]any{"count": len(workflows), "workflows": workflows}) + }, +} + +// workflowListProject reshapes the raw list_workflows response into a clean +// workflow list ({workflowId, name, status/enabled}) — output-projection +// clean output projection. Both the list container and the per-item field names are +// probed defensively across candidate keys, so an empty/unknown shape yields an +// empty list rather than a crash or fabricated data. +func workflowListProject(data map[string]any) []map[string]any { + raw := workflowListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok { + row["workflowId"] = v + } + if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok { + row["name"] = v + } + if v, ok := workflowListFirst(m, "status", "enabled", "isEnabled", "state"); ok { + row["status"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// workflowListResolveList locates the list payload inside the response, +// tolerating a bare top-level array container or nesting one level under a +// common envelope. +func workflowListResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"workflows", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"workflows", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// workflowListFirst returns the first present candidate key's value. +func workflowListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ───────────────────────────────────────────────────────────── +// dashboard: 仪表盘管理(server: aitable / aitable-helper) +// ───────────────────────────────────────────────────────────── + +// DashboardConfigExample 获取仪表盘配置示例(get_dashboard_config_example)。 +var DashboardConfigExample = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-config-example", + Product: serverMain, + Description: "获取 dashboard config 的结构示例", + Intent: "当你准备创建或更新仪表盘、需要先了解 dashboard config 的字段结构长什么样时使用;返回一份配置结构示例供参考。", + Risk: shortcut.RiskRead, + Tips: []string{`dws aitable +dashboard-config-example`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dashboard_config_example", map[string]any{}) + }, +} + +// DashboardGet 获取仪表盘信息(get_dashboard)。 +var DashboardGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-get", + Product: serverMain, + Description: "获取指定 dashboard 的详细信息(含 charts summary)", + Intent: "当你要查看某仪表盘的配置详情及它包含哪些图表(拿 chartId)时使用;返回 dashboard 信息与 charts 概要。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + }, + Tips: []string{`dws aitable +dashboard-get --base-id B --dashboard-id D`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dashboard", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + }) + }, +} + +// DashboardCreate 创建仪表盘(create_dashboard)。 +// DashboardUpdate 更新仪表盘(update_dashboard)。 +var DashboardUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-update", + Product: serverMain, + Description: "更新指定 dashboard 的配置", + Intent: "当你要修改仪表盘的名称或整体配置时使用;会实际更新指定 dashboard 的配置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "config", Type: shortcut.FlagString, Desc: "dashboard 配置 JSON(可选,与 --name 二选一)"}, + {Name: "name", Type: shortcut.FlagString, Desc: "dashboard 名称(可选,与 --config 二选一)"}, + }, + Tips: []string{`dws aitable +dashboard-update --base-id B --dashboard-id D --name "新名称"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + cfg := map[string]any{} + if rt.Changed("config") { + m, err := parseJSONObject("config", rt.Str("config")) + if err != nil { + return err + } + cfg = m + } + if rt.Changed("name") { + cfg["name"] = rt.Str("name") + } + if len(cfg) == 0 { + return fmt.Errorf("必须指定 --config 或 --name") + } + return rt.CallMCP("update_dashboard", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "config": cfg, + }) + }, +} + +// DashboardDelete 删除仪表盘(delete_dashboard)。 +var DashboardDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-delete", + Product: serverMain, + Description: "删除指定 dashboard(级联删除其 chart,不可逆)", + Intent: "当你确认要删除某个仪表盘时使用;会级联删除其下所有图表,不可逆。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, + }, + Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + } + if rt.Changed("reason") { + params["reason"] = rt.Str("reason") + } + return rt.CallMCP("delete_dashboard", params) + }, +} + +// DashboardArrange 自动重排仪表盘图表布局(align_dashboard,server: aitable-helper)。 +var DashboardArrange = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-arrange", + Product: serverHelper, + Description: "对指定仪表盘做服务端智能布局重排", + Intent: "当仪表盘里的图表排布凌乱、你想让系统自动重新排版对齐时使用;会实际调整该仪表盘的图表布局。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + }, + Tips: []string{`dws aitable +dashboard-arrange --base-id B --dashboard-id D`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("align_dashboard", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + }) + }, +} + +// DashboardShareGet 获取仪表盘分享配置(get_dashboard_share)。 +var DashboardShareGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-share-get", + Product: serverMain, + Description: "查询 dashboard 的分享配置", + Intent: "当你要查看某仪表盘是否已开启对外分享及其分享方式时使用;返回 dashboard 的分享配置。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + }, + Tips: []string{`dws aitable +dashboard-share-get --base-id B --dashboard-id D`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dashboard_share", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + }) + }, +} + +// DashboardShareUpdate 更新仪表盘分享配置(update_dashboard_share)。 +var DashboardShareUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+dashboard-share-update", + Product: serverMain, + Description: "开启/关闭 dashboard 分享并可设置分享类型", + Intent: "当你要对外开启或关闭仪表盘分享(可选公开 PUBLIC 或仅组织内 ORG)时使用;会实际改变 dashboard 的分享设置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "enabled", Type: shortcut.FlagBool, Desc: "是否开启分享", Required: true}, + {Name: "share-type", Type: shortcut.FlagString, Desc: "分享类型(仅开启时生效)", Enum: []string{"PUBLIC", "ORG"}}, + {Name: "allow-back-to-doc", Type: shortcut.FlagBool, Desc: "是否允许回到文档(可选)"}, + }, + Tips: []string{`dws aitable +dashboard-share-update --base-id B --dashboard-id D --enabled true --share-type PUBLIC`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "enabled": rt.Bool("enabled"), + } + if rt.Changed("share-type") { + params["shareType"] = rt.Str("share-type") + } + if rt.Changed("allow-back-to-doc") { + params["allowBackToDoc"] = rt.Bool("allow-back-to-doc") + } + return rt.CallMCP("update_dashboard_share", params) + }, +} + +// ───────────────────────────────────────────────────────────── +// chart: 图表管理(server: aitable) +// ───────────────────────────────────────────────────────────── + +// ChartWidgetsExample 获取图表配置示例(get_dashboard_widgets_example)。 +var ChartWidgetsExample = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-widgets-example", + Product: serverMain, + Description: "获取所有图表类型的 widget config 示例", + Intent: "当你准备创建或修改图表、需要先参考各类图表 widget config 的示例结构时使用;返回所有图表类型的配置示例。", + Risk: shortcut.RiskRead, + Tips: []string{`dws aitable +chart-widgets-example`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dashboard_widgets_example", map[string]any{}) + }, +} + +// ChartGet 获取图表信息(get_chart)。 +var ChartGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-get", + Product: serverMain, + Description: "获取指定 chart 的详细信息", + Intent: "当你要查看某个图表的配置详情(统计维度、样式等)时使用;返回指定 chart 的详细信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, + }, + Tips: []string{`dws aitable +chart-get --base-id B --dashboard-id D --chart-id C`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_chart", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "chartId": rt.Str("chart-id"), + }) + }, +} + +// ChartCreate 创建图表(create_chart)。 +// ChartUpdate 更新图表(update_chart)。 +var ChartUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-update", + Product: serverMain, + Description: "更新指定 chart 的配置或布局(--config 必填)", + Intent: "当你要修改某图表的配置(如改名、换统计维度)或调整其在仪表盘上的布局时使用;会实际更新 chart,config 必填。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, + {Name: "config", Type: shortcut.FlagString, Desc: "图表配置 JSON(至少含 chartName)", Required: true}, + {Name: "layout", Type: shortcut.FlagString, Desc: "布局 JSON(可选)"}, + }, + Tips: []string{`dws aitable +chart-update --base-id B --dashboard-id D --chart-id C --config '{"chartName":"柱图"}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + cfg, err := parseJSONObject("config", rt.Str("config")) + if err != nil { + return err + } + params := map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "chartId": rt.Str("chart-id"), + "config": cfg, + } + if rt.Changed("layout") { + layout, err := parseJSONObject("layout", rt.Str("layout")) + if err != nil { + return err + } + params["layout"] = layout + } + return rt.CallMCP("update_chart", params) + }, +} + +// ChartDelete 删除图表(delete_chart)。 +var ChartDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-delete", + Product: serverMain, + Description: "删除指定 chart 及其布局项(不可逆)", + Intent: "当你确认要删除某个图表时使用;会连同其在仪表盘上的布局项一并移除,不可逆。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, + {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, + }, + Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "chartId": rt.Str("chart-id"), + } + if rt.Changed("reason") { + params["reason"] = rt.Str("reason") + } + return rt.CallMCP("delete_chart", params) + }, +} + +// ChartShareGet 获取图表分享配置(get_chart_share)。 +var ChartShareGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-share-get", + Product: serverMain, + Description: "查询 chart 的分享配置", + Intent: "当你要查看某图表是否已开启对外分享及其分享方式时使用;返回 chart 的分享配置。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, + }, + Tips: []string{`dws aitable +chart-share-get --base-id B --dashboard-id D --chart-id C`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_chart_share", map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "chartId": rt.Str("chart-id"), + }) + }, +} + +// ChartShareUpdate 更新图表分享配置(update_chart_share)。 +var ChartShareUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+chart-share-update", + Product: serverMain, + Description: "开启/关闭 chart 分享并可设置分享类型", + Intent: "当你要对外开启或关闭单个图表的分享(可选公开 PUBLIC 或仅组织内 ORG)时使用;会实际改变 chart 的分享设置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, + {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, + {Name: "enabled", Type: shortcut.FlagBool, Desc: "是否开启分享", Required: true}, + {Name: "share-type", Type: shortcut.FlagString, Desc: "分享类型(仅开启时生效)", Enum: []string{"PUBLIC", "ORG"}}, + {Name: "allow-back-to-doc", Type: shortcut.FlagBool, Desc: "是否允许回到文档(可选)"}, + }, + Tips: []string{`dws aitable +chart-share-update --base-id B --dashboard-id D --chart-id C --enabled true --share-type ORG`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "dashboardId": rt.Str("dashboard-id"), + "chartId": rt.Str("chart-id"), + "enabled": rt.Bool("enabled"), + } + if rt.Changed("share-type") { + params["shareType"] = rt.Str("share-type") + } + if rt.Changed("allow-back-to-doc") { + params["allowBackToDoc"] = rt.Bool("allow-back-to-doc") + } + return rt.CallMCP("update_chart_share", params) + }, +} + +// ───────────────────────────────────────────────────────────── +// export / import(server: aitable) +// ───────────────────────────────────────────────────────────── + +// ExportData 导出数据(export_data)。 +var ExportData = shortcut.Shortcut{ + Service: "aitable", + Command: "+export-data", + Product: serverMain, + Description: "导出 AI 表格数据(创建导出任务或按 taskId 续等)", + Intent: "当你要把 AI 表格数据导出为 Excel/附件(可选整个 Base、某张表或某个视图),或用已有 taskId 继续等待之前导出任务完成时使用;返回导出任务状态与下载结果。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "task-id", Type: shortcut.FlagString, Desc: "已有导出任务 ID(传入则续等,不重新创建)"}, + {Name: "scope", Type: shortcut.FlagString, Desc: "导出范围", Enum: []string{"all", "table", "view"}}, + {Name: "format", Type: shortcut.FlagString, Desc: "导出格式", + Enum: []string{"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID(scope=table/view 时)"}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "View ID(scope=view 时)"}, + {Name: "timeout-ms", Type: shortcut.FlagInt, Desc: "同步等待超时(毫秒,可选)"}, + }, + Tips: []string{`dws aitable +export-data --base-id B --scope all --format excel`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"baseId": rt.Str("base-id")} + if rt.Changed("task-id") { + params["taskId"] = rt.Str("task-id") + } else { + if !rt.Changed("scope") || !rt.Changed("format") { + return fmt.Errorf("需指定 --task-id,或同时指定 --scope 和 --format") + } + params["scope"] = rt.Str("scope") + params["format"] = rt.Str("format") + } + if rt.Changed("table-id") { + params["tableId"] = rt.Str("table-id") + } + if rt.Changed("view-id") { + params["viewId"] = rt.Str("view-id") + } + if rt.Changed("timeout-ms") { + params["timeoutMs"] = rt.Int("timeout-ms") + } + return rt.CallMCP("export_data", params) + }, +} + +// ImportUpload 准备导入文件上传(prepare_import_upload)。 +var ImportUpload = shortcut.Shortcut{ + Service: "aitable", + Command: "+import-upload", + Product: serverMain, + Description: "为导入任务申请 OSS 直传地址(uploadUrl / importId)", + Intent: "当你要把本地文件(如 Excel)导入 AI 表格、需要先申请上传地址时使用;返回 uploadUrl 和 importId,供直传文件后再调用导入。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "file-name", Type: shortcut.FlagString, Desc: "文件名(含扩展名)", Required: true}, + {Name: "file-size", Type: shortcut.FlagInt, Desc: "文件大小(字节,可选)"}, + }, + Tips: []string{`dws aitable +import-upload --base-id B --file-name data.xlsx --file-size 204800`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "fileName": rt.Str("file-name"), + } + if rt.Changed("file-size") { + params["fileSize"] = rt.Int("file-size") + } + return rt.CallMCP("prepare_import_upload", params) + }, +} + +// ImportData 导入数据(import_data)。 +var ImportData = shortcut.Shortcut{ + Service: "aitable", + Command: "+import-data", + Product: serverMain, + Description: "将已上传文件导入 AI 表格(新建表或追加到已有表)", + Intent: "当文件已上传、你要真正把它导入成新表或追加到已有表(可设表头行、指定源 Sheet、做字段映射)时使用;会实际写入数据。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "import-id", Type: shortcut.FlagString, Desc: "import upload 返回的 importId", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "追加导入的目标 Table ID(可选)"}, + {Name: "timeout", Type: shortcut.FlagInt, Desc: "等待超时(可选)"}, + {Name: "header-row", Type: shortcut.FlagInt, Desc: "表头行号(可选)"}, + {Name: "src-sheet-name", Type: shortcut.FlagString, Desc: "源 Sheet 名(可选)"}, + {Name: "field-mapping", Type: shortcut.FlagString, Desc: "字段映射 JSON 对象,key=目标字段名 value=源列名(可选)"}, + }, + Tips: []string{`dws aitable +import-data --import-id IMPORT_ID`, `dws aitable +import-data --import-id IMPORT_ID --table-id T`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"importId": rt.Str("import-id")} + if rt.Changed("table-id") { + params["tableId"] = rt.Str("table-id") + } + if rt.Changed("timeout") { + params["timeout"] = rt.Int("timeout") + } + if rt.Changed("header-row") { + params["headerRow"] = rt.Int("header-row") + } + if rt.Changed("src-sheet-name") { + params["srcSheetName"] = rt.Str("src-sheet-name") + } + if rt.Changed("field-mapping") { + m, err := parseJSONObject("field-mapping", rt.Str("field-mapping")) + if err != nil { + return err + } + params["fieldMapping"] = m + } + return rt.CallMCP("import_data", params) + }, +} + +// ───────────────────────────────────────────────────────────── +// advperm / role: 高级权限与自定义角色(server: aitable-helper) +// ───────────────────────────────────────────────────────────── + +// AdvpermEnable 开启高级权限总开关(set_advanced_permission, enabled=true)。 +var AdvpermEnable = shortcut.Shortcut{ + Service: "aitable", + Command: "+advperm-enable", + Product: serverHelper, + Description: "开启指定 Base 的高级权限总开关", + Intent: "当你要为某 Base 打开高级权限总开关、以便后续配置自定义角色和精细权限时使用;会实际开启该 Base 的高级权限。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +advperm-enable --base-id B`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_advanced_permission", map[string]any{ + "baseId": rt.Str("base-id"), + "enabled": true, + }) + }, +} + +// AdvpermDisable 关闭高级权限总开关(set_advanced_permission, enabled=false)。 +var AdvpermDisable = shortcut.Shortcut{ + Service: "aitable", + Command: "+advperm-disable", + Product: serverHelper, + Description: "关闭指定 Base 的高级权限总开关(所有自定义角色失效)", + Intent: "当你要关闭某 Base 的高级权限总开关时使用;会使该 Base 下所有自定义角色失效、影响成员访问权限,请谨慎确认。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +advperm-disable --base-id B --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_advanced_permission", map[string]any{ + "baseId": rt.Str("base-id"), + "enabled": false, + }) + }, +} + +// RoleList 列出 Base 下所有角色(list_roles)。 +var RoleList = shortcut.Shortcut{ + Service: "aitable", + Command: "+role-list", + Product: serverHelper, + Description: "列出指定 Base 下的全部角色", + Intent: "当你要查看某 Base 下配置了哪些角色(拿 roleId)时使用;返回全部角色列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +role-list --base-id B`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_roles", map[string]any{"baseId": rt.Str("base-id")}) + }, +} + +// RoleGet 获取单个角色配置(get_role)。 +var RoleGet = shortcut.Shortcut{ + Service: "aitable", + Command: "+role-get", + Product: serverHelper, + Description: "获取单个角色的完整配置", + Intent: "当你要查看某个角色的完整权限配置(含各表/字段的授权级别)时使用;返回单个角色的详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID", Required: true}, + }, + Tips: []string{`dws aitable +role-get --base-id B --role-id R`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_role", map[string]any{ + "baseId": rt.Str("base-id"), + "roleId": rt.Str("role-id"), + }) + }, +} + +// RoleCreate 创建自定义角色(create_role)。 +var RoleCreate = shortcut.Shortcut{ + Service: "aitable", + Command: "+role-create", + Product: serverHelper, + Description: "在指定 Base 下创建自定义角色", + Intent: "当你要在已开启高级权限的 Base 下新建一个自定义角色并设定其对各表的读写权限时使用;会实际创建角色。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "角色名称", Required: true}, + {Name: "role-type", Type: shortcut.FlagString, Desc: "角色类型(可选)"}, + {Name: "flow-type", Type: shortcut.FlagString, Desc: "流转类型(可选)"}, + {Name: "sub-roles", Type: shortcut.FlagString, Desc: "子角色 JSON 数组(可选)"}, + }, + Tips: []string{`dws aitable +role-create --base-id B --name "市场可读" --sub-roles '[{"targetId":"tbl","targetType":"sheet","authLevel":"read"}]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "name": rt.Str("name"), + } + if rt.Changed("role-type") { + params["roleType"] = rt.Str("role-type") + } + if rt.Changed("flow-type") { + params["flowType"] = rt.Str("flow-type") + } + if rt.Changed("sub-roles") { + sr, err := parseJSONAny("sub-roles", rt.Str("sub-roles")) + if err != nil { + return err + } + params["subRoles"] = sr + } + return rt.CallMCP("create_role", params) + }, +} + +// RoleUpdate 增量更新自定义角色(patch_role)。 +var RoleUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+role-update", + Product: serverHelper, + Description: "按 PATCH 语义增量更新自定义角色", + Intent: "当你要按需增量调整某自定义角色的名称或子权限(PATCH 语义,只改传入项)时使用;会实际更新该角色。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新角色名(可选)"}, + {Name: "role-type", Type: shortcut.FlagString, Desc: "角色类型(可选)"}, + {Name: "flow-type", Type: shortcut.FlagString, Desc: "流转类型(可选)"}, + {Name: "sub-roles", Type: shortcut.FlagString, Desc: "子角色 JSON 数组(可选)"}, + }, + Tips: []string{`dws aitable +role-update --base-id B --role-id R --name "新名字"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "roleId": rt.Str("role-id"), + } + if rt.Changed("name") { + params["name"] = rt.Str("name") + } + if rt.Changed("role-type") { + params["roleType"] = rt.Str("role-type") + } + if rt.Changed("flow-type") { + params["flowType"] = rt.Str("flow-type") + } + if rt.Changed("sub-roles") { + sr, err := parseJSONAny("sub-roles", rt.Str("sub-roles")) + if err != nil { + return err + } + params["subRoles"] = sr + } + return rt.CallMCP("patch_role", params) + }, +} + +// RoleDelete 删除自定义角色(delete_role)。 +var RoleDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+role-delete", + Product: serverHelper, + Description: "删除 Base 下指定的自定义角色(不可逆)", + Intent: "当你确认要删除某个自定义角色时使用;不可逆,删除后被授予该角色的成员将失去对应权限。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID(数字 long 字符串)", Required: true}, + }, + Tips: []string{`dws aitable +role-delete --base-id B --role-id R --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_role", map[string]any{ + "baseId": rt.Str("base-id"), + "roleId": rt.Str("role-id"), + }) + }, +} + +// ───────────────────────────────────────────────────────────── +// section: 文件夹与节点管理(server: aitable-helper) +// ───────────────────────────────────────────────────────────── + +// SectionCreate 创建文件夹(create_section)。 +var SectionCreate = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-create", + Product: serverHelper, + Description: "在指定 Base 下创建文件夹(组织 table / dashboard)", + Intent: "当你要在 Base 内新建文件夹来归类数据表和仪表盘时使用;会实际创建文件夹,可指定父文件夹和插入位置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "文件夹名称", Required: true}, + {Name: "parent-section-id", Type: shortcut.FlagString, Desc: "父文件夹 ID,空表示根目录(可选)"}, + {Name: "index", Type: shortcut.FlagInt, Default: "-1", Desc: "0-based 位置,不传追加到末尾(可选)"}, + }, + Tips: []string{`dws aitable +section-create --base-id B --name 我的文件夹`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "name": rt.Str("name"), + } + if rt.Changed("parent-section-id") { + params["parentSectionId"] = rt.Str("parent-section-id") + } + if idx := rt.Int("index"); idx >= 0 { + params["index"] = idx + } + return rt.CallMCP("create_section", params) + }, +} + +// SectionRename 重命名文件夹(rename_section)。 +var SectionRename = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-rename", + Product: serverHelper, + Description: "重命名指定文件夹", + Intent: "当你要给 Base 内某个文件夹改名时使用;会实际重命名该 section。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true}, + {Name: "new-name", Type: shortcut.FlagString, Desc: "新名称", Required: true}, + }, + Tips: []string{`dws aitable +section-rename --base-id B --section-id S --new-name 新名称`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("rename_section", map[string]any{ + "baseId": rt.Str("base-id"), + "sectionId": rt.Str("section-id"), + "newName": rt.Str("new-name"), + }) + }, +} + +// SectionDelete 删除文件夹(delete_section)。 +var SectionDelete = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-delete", + Product: serverHelper, + Description: "删除指定文件夹(不可逆)", + Intent: "当你确认要删除 Base 内某个文件夹时使用;不可逆,操作前请确认其内节点的处置。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true}, + }, + Tips: []string{`dws aitable +section-delete --base-id B --section-id S --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_section", map[string]any{ + "baseId": rt.Str("base-id"), + "sectionId": rt.Str("section-id"), + }) + }, +} + +// SectionReorder 调整文件夹顺序(reorder_section)。 +var SectionReorder = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-reorder", + Product: serverHelper, + Description: "在当前父文件夹下调整文件夹的展示顺序", + Intent: "当你要在同一父文件夹下调整某文件夹的排列先后顺序时使用;会实际把它移动到指定的 0-based 位置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true}, + {Name: "target-index", Type: shortcut.FlagInt, Desc: "0-based 目标位置", Required: true}, + }, + Tips: []string{`dws aitable +section-reorder --base-id B --section-id S --target-index 0`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("reorder_section", map[string]any{ + "baseId": rt.Str("base-id"), + "sectionId": rt.Str("section-id"), + "targetIndex": rt.Int("target-index"), + }) + }, +} + +// SectionListEmpty 列出空文件夹(list_empty_sections)。 +var SectionListEmpty = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-list-empty", + Product: serverHelper, + Description: "列出指定 Base 下所有没有子节点的空文件夹", + Intent: "当你想清理 Base、需要先找出所有没有任何子节点的空文件夹时使用;返回空文件夹列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +section-list-empty --base-id B`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_empty_sections", map[string]any{"baseId": rt.Str("base-id")}) + }, +} + +// SectionListNodes 列出全部节点(list_nsheet_nodes)。 +var SectionListNodes = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-list-nodes", + Product: serverHelper, + Description: "列出指定 Base 当前版本下的全部 nsheet 节点", + Intent: "当你要总览某 Base 当前版本下的全部节点(表、仪表盘、文件夹等)目录结构时使用;返回所有 nsheet 节点。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + }, + Tips: []string{`dws aitable +section-list-nodes --base-id B`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_nsheet_nodes", map[string]any{"baseId": rt.Str("base-id")}) + }, +} + +// SectionMoveNode 移动节点(move_nsheet_node)。 +var SectionMoveNode = shortcut.Shortcut{ + Service: "aitable", + Command: "+section-move-node", + Product: serverHelper, + Description: "把任意 nsheet 节点移动到目标文件夹下(可选调整位置)", + Intent: "当你要把某张表/仪表盘/文件夹移动到另一个文件夹下、或移到 Base 根目录(可选调整位置)时使用;会实际改变节点在目录树中的位置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "node-id", Type: shortcut.FlagString, Desc: "待移动节点 ID", Required: true}, + {Name: "new-parent-section-id", Type: shortcut.FlagString, Desc: "目标父文件夹 ID,空字符串表示 Base 根目录"}, + {Name: "target-index", Type: shortcut.FlagInt, Default: "-1", Desc: "0-based 全局下标(可选)"}, + }, + Tips: []string{`dws aitable +section-move-node --base-id B --node-id N --new-parent-section-id S`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "nodeId": rt.Str("node-id"), + "newParentSectionId": rt.Str("new-parent-section-id"), + } + if idx := rt.Int("target-index"); idx >= 0 { + params["targetIndex"] = idx + } + return rt.CallMCP("move_nsheet_node", params) + }, +} + +func init() { + shortcut.Register( + BaseList, + BaseSearch, + BaseGet, + BaseGetPrimaryDocID, + BaseUpdate, + BaseDelete, + BaseCopy, + TableGet, + TableUpdate, + TableDelete, + FieldGet, + FieldUpdate, + FieldDelete, + RecordQuery, + RecordUpdate, + RecordDelete, + RecordQueryEmpty, + RecordHistoryList, + RecordShareURL, + RecordUpsert, + RecordPrimaryDocGet, + RecordPrimaryDocCreate, + TemplateSearch, + AttachmentUpload, + ViewGet, + ViewUpdate, + ViewDuplicate, + ViewDelete, + ViewGetLock, + ViewLock, + ViewGetFrozenCols, + ViewSetFrozenCols, + ViewGetRowHeight, + ViewSetRowHeight, + ViewSetFillColorRule, + FormList, + FormDelete, + FormUpdate, + FormFieldList, + FormFieldUpdate, + FormFieldHide, + FormShareGet, + FormShareUpdate, + WorkflowEnable, + WorkflowDisable, + WorkflowGet, + WorkflowList, + DashboardConfigExample, + DashboardGet, + DashboardUpdate, + DashboardDelete, + DashboardArrange, + DashboardShareGet, + DashboardShareUpdate, + ChartWidgetsExample, + ChartGet, + ChartUpdate, + ChartDelete, + ChartShareGet, + ChartShareUpdate, + ExportData, + ImportUpload, + ImportData, + AdvpermEnable, + AdvpermDisable, + RoleList, + RoleGet, + RoleCreate, + RoleUpdate, + RoleDelete, + SectionCreate, + SectionRename, + SectionDelete, + SectionReorder, + SectionListEmpty, + SectionListNodes, + SectionMoveNode, + ) +} diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go new file mode 100644 index 00000000..af9a0fb1 --- /dev/null +++ b/internal/shortcut/attendance/attendance.go @@ -0,0 +1,1553 @@ +// 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 attendance provides declarative shortcuts for the DingTalk attendance +// (考勤) service: attendance records, check results/records, approvals, shifts, +// schedules, class/group/rule management, statistics, settings, reports and +// vacation. Each shortcut maps 1:1 onto an MCP tool declared in +// internal/helpers/attendance.go. +// +// Note: most attendance MCP tools are served by the "attendance-wukong" MCP +// server; a few (get_user_attendance_record, batch_get_employee_shifts, +// query_attendance_group_or_rules) are served by the default "attendance" +// server. The Product field of each shortcut reflects the ground-truth server. +package attendance + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + serverDefault = "attendance" + serverWukong = "attendance-wukong" +) + +// ── shared helpers ────────────────────────────────────────── + +// dayMillis parses a YYYY-MM-DD date into a local millisecond timestamp. +func dayMillis(s string) (int64, error) { + t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(s), time.Local) + if err != nil { + return 0, fmt.Errorf("日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): %w", err) + } + return t.UnixMilli(), nil +} + +// dayDateTime parses a YYYY-MM-DD date and renders it as "yyyy-MM-dd HH:mm:ss". +func dayDateTime(s string) (string, error) { + t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(s), time.Local) + if err != nil { + return "", fmt.Errorf("日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): %w", err) + } + return t.Format("2006-01-02 15:04:05"), nil +} + +// flexMillis parses YYYY-MM-DD or "yyyy-MM-dd HH:mm:ss" into a local ms timestamp. +func flexMillis(s string) (int64, error) { + s = strings.TrimSpace(s) + if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { + return t.UnixMilli(), nil + } + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + return t.UnixMilli(), nil + } + return 0, fmt.Errorf("日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss") +} + +// flexDateTime parses YYYY-MM-DD or "yyyy-MM-dd HH:mm:ss" into "yyyy-MM-dd HH:mm:ss". +func flexDateTime(s string) (string, error) { + s = strings.TrimSpace(s) + if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { + return t.Format("2006-01-02 15:04:05"), nil + } + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + return t.Format("2006-01-02 15:04:05"), nil + } + return "", fmt.Errorf("日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss") +} + +// dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date +// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +func dateToMillis(s string, endOfDay bool) (int64, error) { + s = strings.TrimSpace(s) + if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { + return t.UnixMilli(), nil + } + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + if endOfDay { + return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + } + return t.UnixMilli(), nil + } + return 0, fmt.Errorf("日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss") +} + +// convertClassCheckTime converts "HH:mm" checkTime strings inside a classVO to +// millisecond offsets (1970-01-01 HH:mm in UTC+8), mirroring the helper. +func convertClassCheckTime(classVO map[string]any) { + cst := time.FixedZone("CST", 8*3600) + convertOne := func(obj map[string]any) { + v, ok := obj["checkTime"] + if !ok || v == nil { + return + } + if ct, ok := v.(string); ok { + ct = strings.TrimSpace(ct) + if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil { + obj["checkTime"] = float64(t.UnixMilli()) + } + } + } + if sections, ok := classVO["sections"].([]any); ok { + for _, sec := range sections { + if secMap, ok := sec.(map[string]any); ok { + if times, ok := secMap["times"].([]any); ok { + for _, t := range times { + if tMap, ok := t.(map[string]any); ok { + convertOne(tMap) + } + } + } + } + } + } + if setting, ok := classVO["setting"].(map[string]any); ok { + if restList, ok := setting["topRestTimeList"].([]any); ok { + for _, item := range restList { + if itemMap, ok := item.(map[string]any); ok { + convertOne(itemMap) + } + } + } + } +} + +// approveTypeMapping maps query keywords to bizType numbers (query_user_approve). +var approveTypeMapping = map[string]int{ + "overtime": 1, "加班": 1, + "trip": 2, "travel": 2, "business_trip": 2, "business-trip": 2, "出差": 2, "外出": 2, + "leave": 3, "请假": 3, + "patch": 4, "repair_check": 4, "repair-check": 4, "补卡": 4, +} + +// approveTemplateTypeMapping maps keywords to template approveType strings. +var approveTemplateTypeMapping = map[string]string{ + "repair_check": "REPAIR_CHECK", "repair-check": "REPAIR_CHECK", "patch": "REPAIR_CHECK", "补卡": "REPAIR_CHECK", + "leave": "LEAVE", "请假": "LEAVE", + "overtime": "OVERTIME", "加班": "OVERTIME", + "travel": "TRAVEL", "外出": "TRAVEL", + "out": "OUT", "trip": "OUT", "business_trip": "OUT", "business-trip": "OUT", "出差": "OUT", +} + +// ── record ────────────────────────────────────────────────── + +// GetRecord 查询个人考勤详情(get_user_attendance_record)。 +// ── check ─────────────────────────────────────────────────── + +// CheckResult 查询打卡结果(query_check_result)。 +var CheckResult = shortcut.Shortcut{ + Service: "attendance", + Command: "+check-result", + Product: serverWukong, + Description: "查询用户打卡结果(迟到/早退/缺卡等)", + Intent: "当你需要批量统计一批员工在某段时间的打卡结果(正常/迟到/早退/缺卡等判定结论)时使用,例如做月度考勤汇总或异常人员排查;输入多个 userId(最多 100 人)和不超过 1 个月的日期区间,返回每人每天的打卡结果状态,支持分页。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"}, + }, + Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + from, err := dayDateTime(rt.Str("start")) + if err != nil { + return err + } + to, err := dayDateTime(rt.Str("end")) + if err != nil { + return err + } + return rt.CallMCP("query_check_result", map[string]any{ + "QueryCheckResultRequest": map[string]any{ + "userIds": rt.StrSlice("users"), + "workDateFrom": from, + "workDateTo": to, + "offset": rt.Int("offset"), + "limit": rt.Int("limit"), + }, + }) + }, +} + +// CheckRecord 查询打卡流水(query_check_record)。 +var CheckRecord = shortcut.Shortcut{ + Service: "attendance", + Command: "+check-record", + Product: serverWukong, + Description: "查询用户打卡流水(打卡时间/地点/定位方式)", + Intent: "当你要查看员工每一次实际打卡的原始记录(具体打卡时刻、打卡地点、定位/Wifi/蓝牙等方式)而不是判定结论时使用,例如核实某人是否在指定地点打卡;输入 userId 列表和不超过 1 个月的日期区间,返回逐条打卡流水。与 +check-result 的区别是这里返回明细流水而非迟到早退结论。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + }, + Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`}, + Execute: func(rt *shortcut.RuntimeContext) error { + from, err := dayDateTime(rt.Str("start")) + if err != nil { + return err + } + to, err := dayDateTime(rt.Str("end")) + if err != nil { + return err + } + return rt.CallMCP("query_check_record", map[string]any{ + "QueryCheckRecordRequest": map[string]any{ + "userIds": rt.StrSlice("users"), + "checkDateFrom": from, + "checkDateTo": to, + }, + }) + }, +} + +// ── approve ───────────────────────────────────────────────── + +// ListApprove 查询用户考勤审批单(query_user_approve)。 +var ListApprove = shortcut.Shortcut{ + Service: "attendance", + Command: "+list-approve", + Product: serverWukong, + Description: "查询用户考勤审批单(补卡/加班/请假/出差外出)", + Intent: "当你想查看某些员工已提交的考勤类审批单(加班、请假、出差外出、补卡)时使用,例如核对某人这段时间请了几次假或有没有补卡审批;输入 userId 列表、审批类型(overtime/加班、leave/请假、trip/出差外出、patch/补卡)和日期区间,返回匹配的审批单记录。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true}, + }, + Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`}, + Execute: func(rt *shortcut.RuntimeContext) error { + from, err := dayDateTime(rt.Str("start")) + if err != nil { + return err + } + to, err := dayDateTime(rt.Str("end")) + if err != nil { + return err + } + var bizTypes []int + for _, t := range rt.StrSlice("types") { + s := strings.TrimSpace(t) + if s == "" { + continue + } + bizType, ok := approveTypeMapping[strings.ToLower(s)] + if !ok { + return fmt.Errorf("无效的审批类型: %s,支持: overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", s) + } + bizTypes = append(bizTypes, bizType) + } + return rt.CallMCP("query_user_approve", map[string]any{ + "QueryUserApproveRequest": map[string]any{ + "userIds": rt.StrSlice("users"), + "bizTypes": bizTypes, + "fromDate": from, + "toDate": to, + }, + }) + }, +} + +// GetApproveTemplate 查询考勤审批提交链接(query_at_approve_template)。 +var GetApproveTemplate = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-approve-template", + Product: serverWukong, + Description: "查询补卡/请假/加班/外出/出差审批提交链接", + Intent: "当用户想自己发起一条考勤审批(补卡、请假、加班、外出、出差)、需要拿到对应审批表单的提交入口链接时使用;输入审批类型(如 leave/请假),返回可直接打开填写并提交的审批链接。本命令只返回链接、不代替用户提交审批。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Desc: "审批类型:repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差(或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT)", Required: true}, + }, + Tips: []string{`dws attendance +get-approve-template --type leave`}, + Execute: func(rt *shortcut.RuntimeContext) error { + input := strings.TrimSpace(rt.Str("type")) + approveType, ok := approveTemplateTypeMapping[strings.ToLower(input)] + if !ok { + approveType = strings.ToUpper(input) + } + switch approveType { + case "REPAIR_CHECK", "LEAVE", "OVERTIME", "TRAVEL", "OUT": + default: + return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input) + } + return rt.CallMCP("query_at_approve_template", map[string]any{ + "approveType": approveType, + }) + }, +} + +// ── shift ─────────────────────────────────────────────────── + +// ListShift 批量查询员工班次信息(batch_get_employee_shifts)。 +// ── schedule ──────────────────────────────────────────────── + +// GetSchedule 获取指定用户的排班记录(getScheduleByRange)。 +var GetSchedule = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-schedule", + Product: serverWukong, + Description: "获取指定用户一段时间内的排班记录", + Intent: "当你要查看排班制考勤组下员工在某段时间的排班记录(含排班 id、班次、是否休息)时使用,尤其是需要拿到排班 id 用于后续 BOSS 改签打卡(+boss-check)的场景;输入 userId 列表和起止时间,返回逐日排班明细。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + }, + Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`}, + Execute: func(rt *shortcut.RuntimeContext) error { + begin, err := dateToMillis(rt.Str("start"), false) + if err != nil { + return err + } + end, err := dateToMillis(rt.Str("end"), true) + if err != nil { + return err + } + return rt.CallMCP("getScheduleByRange", map[string]any{ + "GetScheduleByRangeRequest": map[string]any{ + "userIdList": rt.StrSlice("users"), + "workDateBegin": begin, + "workDateEnd": end, + }, + }) + }, +} + +// ImportSchedule 导入排班记录到排班制考勤组(generateTurnSchedule)。 +var ImportSchedule = shortcut.Shortcut{ + Service: "attendance", + Command: "+import-schedule", + Product: serverWukong, + Description: "导入排班记录到排班制考勤组", + Intent: "当管理员需要为排班制考勤组批量设置员工某几天上哪个班或休息时使用;输入考勤组 ID 和一组排班记录 JSON(含 userId/workDate/classId/isRest),会实际写入并生成员工的排班,直接影响这些人后续的打卡要求,务必确认 classId 和日期正确。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "考勤组 ID", Required: true}, + {Name: "schedules", Type: shortcut.FlagString, Desc: `排班记录 JSON 数组,每条含 userId/workDate(yyyy-MM-dd HH:mm:ss)/classId/isRest(Y|N)`, Required: true}, + }, + Tips: []string{`dws attendance +import-schedule --group-id 123456 --schedules '[{"userId":"user001","workDate":"2026-04-22 09:00:00","classId":123,"isRest":"N"}]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + var scheduleVOS []map[string]any + if err := json.Unmarshal([]byte(rt.Str("schedules")), &scheduleVOS); err != nil { + return fmt.Errorf("invalid --schedules JSON: %w", err) + } + if len(scheduleVOS) == 0 { + return fmt.Errorf("--schedules 至少需要一条排班记录") + } + required := []string{"userId", "workDate", "classId", "isRest"} + for i, sched := range scheduleVOS { + for _, field := range required { + if _, ok := sched[field]; !ok { + return fmt.Errorf("schedule[%d] 缺少必填字段: %s(必填: userId, workDate, classId, isRest)", i, field) + } + } + if wd, ok := sched["workDate"].(string); ok { + norm, err := flexDateTime(wd) + if err != nil { + return fmt.Errorf("schedule[%d] workDate 格式错误: %w", i, err) + } + sched["workDate"] = norm + } + } + return rt.CallMCP("generateTurnSchedule", map[string]any{ + "GenerateTurnScheduleRequest": map[string]any{ + "groupId": int64(rt.Int("group-id")), + "scheduleVOS": scheduleVOS, + "param": map[string]any{ + "useHistoryGroupAndShift": false, + }, + }, + }) + }, +} + +// ── class ─────────────────────────────────────────────────── + +// SearchClass 查询当前用户可管理的班次列表(get_class_list)。 +var SearchClass = shortcut.Shortcut{ + Service: "attendance", + Command: "+search-class", + Product: serverWukong, + Description: "查询当前用户可管理的班次详情列表", + Intent: "当你要浏览或按名称查找当前用户能管理的班次、以便拿到班次 ID 用于建组、排班或改班次时使用;可选按班次名关键字模糊搜索、按 ALL/我负责的过滤并分页,返回班次列表及详情。要看某个具体班次的完整配置用 +get-class。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"}, + {Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"}, + }, + Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, + Execute: func(rt *shortcut.RuntimeContext) error { + pageQuery := map[string]any{} + if v := rt.Int("page"); v > 0 { + pageQuery["pageIndex"] = v + } + if v := rt.Int("limit"); v > 0 { + pageQuery["pageSize"] = v + } + shiftParam := map[string]any{} + if v := rt.Str("query"); v != "" { + shiftParam["searchName"] = v + } + if v := rt.Str("filter-type"); v != "" { + shiftParam["filterType"] = v + } + params := map[string]any{} + if len(pageQuery) > 0 { + params["PageQuery"] = pageQuery + } + if len(shiftParam) > 0 { + params["ShiftParamVO"] = shiftParam + } + data, err := rt.CallMCPData(serverWukong, "get_class_list", params) + if err != nil { + return err + } + classes := searchClassProject(data) + return rt.Output(map[string]any{"count": len(classes), "classes": classes}) + }, +} + +// searchClassProject reshapes the raw get_class_list response into a clean class +// list ({classId, name, ownerName}) — clean output projection. Both +// the list container and per-item field names are probed defensively across +// candidate keys, so an unknown/empty shape yields an empty list rather than a +// crash or fabricated data. +func searchClassProject(data map[string]any) []map[string]any { + raw := attendanceResolveList(data, "classList", "shiftList", "list", "items") + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { + row["classId"] = v + } + if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { + row["name"] = v + } + if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok { + row["ownerName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// attendanceResolveList locates the list payload inside a response, tolerating a +// bare top-level array under a common container key or nesting one level deeper +// under result/data. extraKeys are probed ahead of the generic containers. +func attendanceResolveList(data map[string]any, extraKeys ...string) []any { + keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items") + for _, key := range keys { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// attendanceFirst returns the first present candidate key's value. +func attendanceFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// GetClass 根据班次 ID 查询班次详情(get_class_detail)。 +var GetClass = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-class", + Product: serverWukong, + Description: "根据班次 ID 查询班次详情", + Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + }, + Tips: []string{`dws attendance +get-class --class-id 1170996821`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_class_detail", map[string]any{ + "classId": int64(rt.Int("class-id")), + }) + }, +} + +// CreateClass 创建班次(create_class_setting)。 +var CreateClass = shortcut.Shortcut{ + Service: "attendance", + Command: "+create-class", + Product: serverWukong, + Description: "创建班次(checkTime 用 HH:mm,自动转时间戳)", + Intent: "当你要新建一个考勤班次(定义几点上班、几点下班、休息时段等)供后续排班或考勤组使用时使用;输入班次名称和包含 sections 上下班时间段的 class-vo JSON(checkTime 用 HH:mm),会实际在企业里创建一个新班次。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "班次名称", Required: true}, + {Name: "owner", Type: shortcut.FlagString, Desc: "班次负责人 userId"}, + {Name: "class-vo", Type: shortcut.FlagString, Desc: `完整 TopAtClassVO JSON,必须含 sections(如 {"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]})`, Required: true}, + }, + Tips: []string{`dws attendance +create-class --name "早班" --class-vo '{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"08:00","across":0},{"checkType":"OffDuty","checkTime":"17:00","across":0}]}]}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + classVO := map[string]any{} + if raw := rt.Str("class-vo"); raw != "" { + if err := json.Unmarshal([]byte(raw), &classVO); err != nil { + return fmt.Errorf("invalid --class-vo JSON: %w", err) + } + } + if rt.Changed("name") { + classVO["name"] = rt.Str("name") + } + if rt.Changed("owner") { + classVO["owner"] = rt.Str("owner") + } + if classVO["name"] == nil || classVO["name"] == "" { + return fmt.Errorf("--name 是必填项,请指定班次名称") + } + if classVO["sections"] == nil { + return fmt.Errorf("--class-vo 中必须包含 sections 字段(班次上下班时间段)") + } + convertClassCheckTime(classVO) + return rt.CallMCP("create_class_setting", map[string]any{ + "TopAtClassVO": classVO, + }) + }, +} + +// UpdateClass 更新班次(update_class_setting)。 +var UpdateClass = shortcut.Shortcut{ + Service: "attendance", + Command: "+update-class", + Product: serverWukong, + Description: "更新已有班次(仅传要修改的字段)", + Intent: "当你要修改一个已存在班次的配置(改名、换负责人或调整上下班时间段)时使用;输入班次 ID 和需要变更的字段(其余保持原值),会实际更新该班次设置,进而影响所有使用该班次的员工打卡要求。建议先用 +get-class 确认现状。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "班次名称(不传则保持原值)"}, + {Name: "owner", Type: shortcut.FlagString, Desc: "班次负责人 userId(不传则保持原值)"}, + {Name: "class-vo", Type: shortcut.FlagString, Desc: "完整 TopAtClassVO JSON(checkTime 用 HH:mm,自动转时间戳)"}, + }, + Tips: []string{`dws attendance +update-class --class-id 1170996821 --name "新早班"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + classVO := map[string]any{} + if raw := rt.Str("class-vo"); raw != "" { + if err := json.Unmarshal([]byte(raw), &classVO); err != nil { + return fmt.Errorf("invalid --class-vo JSON: %w", err) + } + } + classVO["id"] = int64(rt.Int("class-id")) + if rt.Changed("name") { + classVO["name"] = rt.Str("name") + } + if rt.Changed("owner") { + classVO["owner"] = rt.Str("owner") + } + convertClassCheckTime(classVO) + return rt.CallMCP("update_class_setting", map[string]any{ + "TopAtClassVO": classVO, + }) + }, +} + +// ── adjustment rule ───────────────────────────────────────── + +// GetAdjustmentRule 查询补卡规则详情(get_adjustment_rule_detail)。 +var GetAdjustmentRule = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-adjustment-rule", + Product: serverWukong, + Description: "根据补卡规则主键 ID 查询补卡规则详情", + Intent: "当你已知某条补卡规则的主键 ID、想查看它的具体规则内容(每月可补卡次数、时限、适用范围等)时使用;输入 adjustmentId,返回该补卡规则的完整详情。不知道 ID 时先用 +search-adjustment-rule 列出。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "adjustment-id", Type: shortcut.FlagInt, Desc: "补卡规则主键 ID", Required: true}, + }, + Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_adjustment_rule_detail", map[string]any{ + "adjustmentId": int64(rt.Int("adjustment-id")), + }) + }, +} + +// SearchAdjustmentRule 查询补卡规则列表(get_adjustment_rule)。 +var SearchAdjustmentRule = shortcut.Shortcut{ + Service: "attendance", + Command: "+search-adjustment-rule", + Product: serverWukong, + Description: "查询当前用户可管理的补卡规则列表", + Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + }, + Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + param := map[string]any{} + if v := rt.Str("query"); v != "" { + param["name"] = v + } + page := rt.Int("page") + if page <= 0 { + page = 1 + } + limit := rt.Int("limit") + if limit <= 0 { + limit = 20 + } + param["currentPage"] = page + param["pageSize"] = limit + data, err := rt.CallMCPData(serverWukong, "get_adjustment_rule", map[string]any{ + "ATRuleQueryParam": param, + }) + if err != nil { + return err + } + rules := searchRuleProject(data) + return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + }, +} + +// searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule +// response into a clean rule list ({ruleId, name}) — output-projection fidelity +// for clean output. The list container and per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func searchRuleProject(data map[string]any) []map[string]any { + raw := attendanceResolveList(data, "ruleList", "rules", "list", "items") + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { + row["ruleId"] = v + } + if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { + row["name"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ── overtime rule ─────────────────────────────────────────── + +// GetOvertimeRule 查询加班规则详情(get_overtime_rule_detail)。 +var GetOvertimeRule = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-overtime-rule", + Product: serverWukong, + Description: "根据加班规则主键 ID 查询加班规则详情", + Intent: "当你已知某条加班规则的主键 ID、想查看它的具体内容(工作日/休息日/节假日加班计算方式、适用范围等)时使用;输入 overtimeId,返回该加班规则的完整详情。不知道 ID 时先用 +search-overtime-rule 列出。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true}, + }, + Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_overtime_rule_detail", map[string]any{ + "overtimeId": int64(rt.Int("overtime-id")), + }) + }, +} + +// SearchOvertimeRule 查询加班规则列表(get_overtime_rule)。 +var SearchOvertimeRule = shortcut.Shortcut{ + Service: "attendance", + Command: "+search-overtime-rule", + Product: serverWukong, + Description: "查询当前用户可管理的加班规则列表", + Intent: "当你要浏览或按名称查找当前用户可管理的加班规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回加班规则列表。要看某条规则的完整内容用 +get-overtime-rule。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + }, + Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + param := map[string]any{} + if v := rt.Str("query"); v != "" { + param["name"] = v + } + page := rt.Int("page") + if page <= 0 { + page = 1 + } + limit := rt.Int("limit") + if limit <= 0 { + limit = 20 + } + param["currentPage"] = page + param["pageSize"] = limit + data, err := rt.CallMCPData(serverWukong, "get_overtime_rule", map[string]any{ + "ATRuleQueryParam": param, + }) + if err != nil { + return err + } + rules := searchRuleProject(data) + return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + }, +} + +// ── group ─────────────────────────────────────────────────── + +// SearchGroup 查询当前用户可管理的考勤组列表(get_simple_groups)。 +var SearchGroup = shortcut.Shortcut{ + Service: "attendance", + Command: "+search-group", + Product: serverWukong, + Description: "查询当前用户可管理的考勤组列表", + Intent: "当你要浏览或按名称查找当前用户可管理的考勤组、以便拿到考勤组 ID 用于查看详情、改成员或改配置时使用;可选按名称关键字、考勤组类型(固定班制/排班制/自由工时)过滤,并可选带出定位/Wifi/蓝牙信息,分页返回考勤组简要列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "考勤组名称关键字,模糊搜索"}, + {Name: "type", Type: shortcut.FlagString, Enum: []string{"FIXED", "TURN", "NONE"}, Desc: "考勤组类型:FIXED 固定班制 / TURN 排班制 / NONE 自由工时"}, + {Name: "query-position", Type: shortcut.FlagBool, Desc: "是否查询地理定位和 Wifi 名称"}, + {Name: "query-ble", Type: shortcut.FlagBool, Desc: "是否查询蓝牙设备列表"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + }, + Tips: []string{`dws attendance +search-group --query "研发" --type FIXED --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + searchParam := map[string]any{} + if v := rt.Str("query"); v != "" { + searchParam["queryStr"] = v + } + if v := rt.Str("type"); v != "" { + searchParam["type"] = v + } + if rt.Changed("query-position") { + searchParam["queryPositionAndWifiNames"] = rt.Bool("query-position") + } + if rt.Changed("query-ble") { + searchParam["queryBleDeviceList"] = rt.Bool("query-ble") + } + if len(searchParam) == 0 { + searchParam["queryPositionAndWifiNames"] = false + searchParam["queryBleDeviceList"] = false + } + page := rt.Int("page") + if page <= 0 { + page = 1 + } + limit := rt.Int("limit") + if limit <= 0 { + limit = 20 + } + data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{ + "param": searchParam, + "pageQuery": map[string]any{ + "pageIndex": page, + "pageSize": limit, + }, + }) + if err != nil { + return err + } + groups := searchGroupProject(data) + return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + }, +} + +// searchGroupProject reshapes the raw get_simple_groups response into a clean +// attendance-group list ({groupId, name, type}) — clean output projection. +// The list container and per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func searchGroupProject(data map[string]any) []map[string]any { + raw := attendanceResolveList(data, "groupList", "groups", "list", "items") + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok { + row["groupId"] = v + } + if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok { + row["name"] = v + } + if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok { + row["type"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。 +var GetGroup = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-group", + Product: serverWukong, + Description: "根据考勤组 ID 查询考勤组全量信息", + Intent: "当你已知考勤组 ID、想一次性看它的全部配置(成员、班次、打卡地点、Wifi、蓝牙、规则等)时使用,例如修改考勤组前先了解现状;输入考勤组 ID,返回该组的全量信息。只想取部分子集时用 +get-group-filtered。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "考勤组 ID", Required: true}, + }, + Tips: []string{`dws attendance +get-group --group-id 123456`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_group_detail", map[string]any{ + "groupId": int64(rt.Int("group-id")), + }) + }, +} + +// GetGroupFiltered 按需查询考勤组成员/地址/蓝牙/Wifi(get_group_filtered_detail)。 +var GetGroupFiltered = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-group-filtered", + Product: serverWukong, + Description: "按需查询考勤组成员/打卡地址/蓝牙/Wifi 子集", + Intent: "当你只想要考勤组的某几类信息(成员、打卡地址、Wifi、蓝牙),不需要整组全量数据以减少返回体积时使用;输入考勤组 ID 并用开关指定要哪些子集,只返回勾选的部分。需要全部信息时用 +get-group。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "考勤组 ID", Required: true}, + {Name: "member", Type: shortcut.FlagBool, Desc: "是否查询考勤组成员信息"}, + {Name: "position", Type: shortcut.FlagBool, Desc: "是否查询打卡地址"}, + {Name: "wifi", Type: shortcut.FlagBool, Desc: "是否查询打卡 Wifi"}, + {Name: "bles", Type: shortcut.FlagBool, Desc: "是否查询打卡蓝牙"}, + }, + Tips: []string{`dws attendance +get-group-filtered --group-id 123456 --member --position`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_group_filtered_detail", map[string]any{ + "groupId": int64(rt.Int("group-id")), + "GroupResultFilter": map[string]any{ + "includeMember": rt.Bool("member"), + "includePosition": rt.Bool("position"), + "includeWifi": rt.Bool("wifi"), + "includeBles": rt.Bool("bles"), + }, + }) + }, +} + +// UpdateGroupMembers 更新考勤组成员(update_group_member)。 +var UpdateGroupMembers = shortcut.Shortcut{ + Service: "attendance", + Command: "+update-group-members", + Product: serverWukong, + Description: "更新考勤组成员(增删考勤人员/部门/无需考勤人员)", + Intent: "当你要把员工或部门加入/移出某个考勤组,或调整无需考勤人员名单时使用,例如新人入职后加入考勤组、离职后移出;输入考勤组 ID 和要增删的 userId/部门列表,会实际变更该考勤组成员,直接影响这些人是否需要打卡。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "考勤组 ID", Required: true}, + {Name: "add-users", Type: shortcut.FlagStringSlice, Desc: "添加考勤人员 userId 列表,逗号分隔,最多 20 个"}, + {Name: "remove-users", Type: shortcut.FlagStringSlice, Desc: "删除考勤人员 userId 列表,逗号分隔,最多 20 个"}, + {Name: "add-extra-users", Type: shortcut.FlagStringSlice, Desc: "添加无需考勤人员 userId 列表,逗号分隔,最多 20 个"}, + {Name: "remove-extra-users", Type: shortcut.FlagStringSlice, Desc: "删除无需考勤成员 userId 列表,逗号分隔,最多 20 个"}, + {Name: "add-depts", Type: shortcut.FlagStringSlice, Desc: "添加考勤部门 ID 列表,逗号分隔(全公司根部门 id 为 -1)"}, + {Name: "remove-depts", Type: shortcut.FlagStringSlice, Desc: "删除考勤部门 ID 列表,逗号分隔(全公司根部门 id 为 -1)"}, + }, + Tips: []string{`dws attendance +update-group-members --group-id 123456 --add-users userId1,userId2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + updateParam := map[string]any{} + if v := rt.StrSlice("add-users"); len(v) > 0 { + updateParam["addUsers"] = v + } + if v := rt.StrSlice("remove-users"); len(v) > 0 { + updateParam["removeUsers"] = v + } + if v := rt.StrSlice("add-extra-users"); len(v) > 0 { + updateParam["addExtraUsers"] = v + } + if v := rt.StrSlice("remove-extra-users"); len(v) > 0 { + updateParam["removeExtraUsers"] = v + } + if v := rt.StrSlice("add-depts"); len(v) > 0 { + updateParam["addDepts"] = v + } + if v := rt.StrSlice("remove-depts"); len(v) > 0 { + updateParam["removeDepts"] = v + } + if len(updateParam) == 0 { + return fmt.Errorf("至少需要指定一个变更项:--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts") + } + return rt.CallMCP("update_group_member", map[string]any{ + "groupId": int64(rt.Int("group-id")), + "updateParam": updateParam, + }) + }, +} + +// CreateGroup 创建考勤组(create_group_setting)。 +var CreateGroup = shortcut.Shortcut{ + Service: "attendance", + Command: "+create-group", + Product: serverWukong, + Description: "创建考勤组(复杂子对象用 --group-vo JSON 传入)", + Intent: "当你要为团队新建一个考勤组(选定固定班制/排班制/自由工时、班次、打卡范围等)时使用;输入考勤组名称、类型和 group-vo JSON(固定班制须含 workDayClassList 和 defaultClassId),会实际创建一个新的考勤组。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "考勤组名称", Required: true}, + {Name: "type", Type: shortcut.FlagString, Enum: []string{"FIXED", "TURN", "NONE"}, Desc: "考勤组类型:FIXED 固定班制 / TURN 排班制 / NONE 自由工时", Required: true}, + {Name: "owner", Type: shortcut.FlagString, Desc: "考勤组主负责人 userId"}, + {Name: "group-vo", Type: shortcut.FlagString, Desc: `完整 groupVO JSON(FIXED 时须含 workDayClassList 和 defaultClassId)`}, + }, + Tips: []string{`dws attendance +create-group --name "研发考勤组" --type FIXED --group-vo '{"defaultClassId":1170996821,"workDayClassList":[0,1170996821,0,0,0,0,0]}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + groupType := rt.Str("type") + groupVO := map[string]any{} + if raw := rt.Str("group-vo"); raw != "" { + if err := json.Unmarshal([]byte(raw), &groupVO); err != nil { + return fmt.Errorf("invalid --group-vo JSON: %w", err) + } + } + groupVO["name"] = rt.Str("name") + groupVO["type"] = groupType + if rt.Changed("owner") { + groupVO["owner"] = rt.Str("owner") + } + if groupType == "FIXED" { + workDayClassList, ok := groupVO["workDayClassList"] + if !ok || workDayClassList == nil { + return fmt.Errorf("固定班制(FIXED)时 --group-vo 必须包含 workDayClassList(工作日班次列表,不能为空)") + } + if list, ok := workDayClassList.([]any); ok && len(list) == 0 { + return fmt.Errorf("workDayClassList 不能为空数组,至少需包含一个班次 ID") + } + if defaultClassID, ok := groupVO["defaultClassId"]; !ok || defaultClassID == nil { + return fmt.Errorf("固定班制(FIXED)时 --group-vo 必须包含 defaultClassId(默认班次 ID)") + } + } + return rt.CallMCP("create_group_setting", map[string]any{ + "groupVO": groupVO, + }) + }, +} + +// UpdateGroup 更新考勤组配置(update_group_setting)。 +var UpdateGroup = shortcut.Shortcut{ + Service: "attendance", + Command: "+update-group", + Product: serverWukong, + Description: "更新考勤组配置(仅修改需要变更的字段)", + Intent: "当你要修改已存在考勤组的配置(改名、改类型/负责人、是否允许外勤打卡、更换所选班次等)时使用;输入考勤组 ID 和需要变更的字段,会实际更新该组配置,影响组内成员的打卡规则。改成员用 +update-group-members,建议先用 +get-group 确认现状。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "考勤组 ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "考勤组名称"}, + {Name: "type", Type: shortcut.FlagString, Enum: []string{"FIXED", "TURN", "NONE"}, Desc: "考勤组类型:FIXED / TURN / NONE"}, + {Name: "owner", Type: shortcut.FlagString, Desc: "考勤组主负责人 userId"}, + {Name: "enable-outside-check", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "是否允许外勤打卡,传 true 或 false"}, + {Name: "class-ids", Type: shortcut.FlagString, Desc: `所选班次 id 列表 JSON 数组,如 '[123,456]'`}, + {Name: "group-vo", Type: shortcut.FlagString, Desc: "完整 groupVO JSON,用于修改复杂子对象"}, + }, + Tips: []string{`dws attendance +update-group --group-id 123456 --name "研发考勤组"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + groupVO := map[string]any{} + if raw := rt.Str("group-vo"); raw != "" { + if err := json.Unmarshal([]byte(raw), &groupVO); err != nil { + return fmt.Errorf("invalid --group-vo JSON: %w", err) + } + } + if rt.Changed("name") { + groupVO["name"] = rt.Str("name") + } + if rt.Changed("type") { + groupVO["type"] = rt.Str("type") + } + if rt.Changed("owner") { + groupVO["owner"] = rt.Str("owner") + } + if rt.Changed("enable-outside-check") { + v, err := strconv.ParseBool(rt.Str("enable-outside-check")) + if err != nil { + return fmt.Errorf("--enable-outside-check 值必须为 true 或 false") + } + groupVO["enableOutsideCheck"] = v + } + if rt.Changed("class-ids") { + var ids []any + if err := json.Unmarshal([]byte(rt.Str("class-ids")), &ids); err != nil { + return fmt.Errorf("invalid --class-ids JSON array: %w", err) + } + groupVO["classIds"] = ids + } + if len(groupVO) == 0 { + return fmt.Errorf("至少需要指定一个修改项:--name / --type / --owner / --enable-outside-check / --class-ids / --group-vo") + } + groupVO["id"] = int64(rt.Int("group-id")) + return rt.CallMCP("update_group_setting", map[string]any{ + "groupId": int64(rt.Int("group-id")), + "groupVO": groupVO, + }) + }, +} + +// ── summary ───────────────────────────────────────────────── + +// GetSummary 查询个人考勤统计摘要(get_user_attendance_summary)。 +var GetSummary = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-summary", + Product: serverWukong, + Description: "查询某个人的考勤统计摘要(周/月)", + Intent: "当你想快速了解某个人一周或一月的考勤汇总(出勤天数、迟到早退次数、加班、请假时长等统计口径)而不是逐天明细时使用;输入 userId、所在周期内的任意日期和统计类型(week/month),返回该周期的考勤统计摘要。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "user", Type: shortcut.FlagString, Desc: "钉钉用户 userId", Required: true}, + {Name: "date", Type: shortcut.FlagString, Desc: "查询日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "stats-type", Type: shortcut.FlagString, Enum: []string{"week", "month"}, Desc: "统计类型:week 周统计 / month 月统计", Required: true}, + }, + Tips: []string{`dws attendance +get-summary --user USER_ID --date 2026-03-12 --stats-type week`}, + Execute: func(rt *shortcut.RuntimeContext) error { + queryDate, err := flexMillis(rt.Str("date")) + if err != nil { + return err + } + return rt.CallMCP("get_user_attendance_summary", map[string]any{ + "userId": rt.Str("user"), + "queryDate": queryDate, + "statsType": rt.Str("stats-type"), + }) + }, +} + +// ── rules ─────────────────────────────────────────────────── + +// GetRules 查询考勤组与考勤规则(query_attendance_group_or_rules)。 +// ── self setting ──────────────────────────────────────────── + +// GetSelfSetting 查询个人规则设置(query_self_setting)。 +var GetSelfSetting = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-self-setting", + Product: serverWukong, + Description: "查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等)", + Intent: "当你想查看某个用户在指定场景下的个人考勤规则开关配置(如打卡提醒、极速打卡、缺卡提醒、考勤结果通知等是否开启)时使用;输入设置场景 settingScene 和用户 userId,返回该用户对应场景的个人设置。查企业全局设置用 +get-global-setting。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true}, + }, + Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("query_self_setting", map[string]any{ + "RuleMcpQuerySelfSettingRequest": map[string]any{ + "settingScene": rt.Str("setting-scene"), + "userId": rt.Str("user"), + }, + }) + }, +} + +// ── global setting ────────────────────────────────────────── + +// GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。 +var GetGlobalSetting = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-global-setting", + Product: serverWukong, + Description: "查询全局规则设置(仅管理员)", + Intent: "当管理员想查看企业层面某场景的全局考勤规则设置(如全公司的打卡提醒、极速打卡、缺卡提醒等默认配置)时使用;输入设置场景 settingScene 和范围确认,返回企业全局设置。仅管理员可用;查某个人的设置用 +get-self-setting。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, + {Name: "scope", Type: shortcut.FlagString, Enum: []string{"企业", "全公司", "所有人"}, Desc: "全局范围确认:企业/全公司/所有人", Required: true}, + }, + Tips: []string{`dws attendance +get-global-setting --scope 企业 --setting-scene checkRemind`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("query_global_setting", map[string]any{ + "RuleMcpQueryGlobalSettingRequest": map[string]any{ + "settingScene": rt.Str("setting-scene"), + }, + }) + }, +} + +// ── report ────────────────────────────────────────────────── + +// ListReportColumns 获取企业考勤字段列表(get_report_columns)。 +var ListReportColumns = shortcut.Shortcut{ + Service: "attendance", + Command: "+list-report-columns", + Product: serverWukong, + Description: "获取企业考勤报表字段列表(仅管理员)", + Intent: "当你准备查考勤报表数据、需要先知道企业有哪些可查字段及其字段 ID(如出勤天数、迟到次数、加班时长等列)时使用;无需参数,返回全部报表字段列表,拿到 columnId 后再配合 +query-report-data 查具体数值。仅管理员可用。", + Risk: shortcut.RiskRead, + Tips: []string{`dws attendance +list-report-columns`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_report_columns", map[string]any{}) + }, +} + +// QueryReportData 根据字段查询考勤数据(get_report_columns_value)。 +var QueryReportData = shortcut.Shortcut{ + Service: "attendance", + Command: "+query-report-data", + Product: serverWukong, + Description: "根据字段查询考勤报表数据(仅管理员)", + Intent: "当你要按指定考勤字段导出/查询一批员工在某时间段的报表数值(如各人的出勤天数、迟到次数、加班时长)时使用,常用于做考勤汇总或核算;输入 userId 列表(最多 20 人)、字段 ID 列表(来自 +list-report-columns)和不超过 32 天的时间区间,返回对应字段的数据。仅管理员可用。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标用户 userId 列表,逗号分隔,最多 20 人", Required: true}, + {Name: "columns", Type: shortcut.FlagStringSlice, Desc: "字段 ID 列表,逗号分隔(可用 +list-report-columns 获取)", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度不超过 32 天", Required: true}, + }, + Tips: []string{`dws attendance +query-report-data --users userId1,userId2 --columns 1001,1002 --start "2026-03-01 00:00:00" --end "2026-03-31 23:59:59"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + startStr := rt.Str("start") + endStr := rt.Str("end") + if _, err := time.ParseInLocation("2006-01-02 15:04:05", startStr, time.Local); err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if _, err := time.ParseInLocation("2006-01-02 15:04:05", endStr, time.Local); err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + var columnIds []int64 + for _, c := range rt.StrSlice("columns") { + c = strings.TrimSpace(c) + if c == "" { + continue + } + id, err := strconv.ParseInt(c, 10, 64) + if err != nil { + return fmt.Errorf("无效的字段 ID %q,必须为数字: %w", c, err) + } + columnIds = append(columnIds, id) + } + return rt.CallMCP("get_report_columns_value", map[string]any{ + "McpQueryParam": map[string]any{ + "targetUserIds": rt.StrSlice("users"), + "columnIds": columnIds, + "fromDate": startStr, + "toDate": endStr, + }, + }) + }, +} + +// QueryReportLeave 查询用户假期数据(get_leave_time_by_leave_names)。 +var QueryReportLeave = shortcut.Shortcut{ + Service: "attendance", + Command: "+query-report-leave", + Product: serverWukong, + Description: "查询用户假期数据(仅管理员)", + Intent: "当你要统计一批员工在某时间段内按假期类型的休假时长(如各人休了多少年假、病假)时使用,常用于假期核算或报表;输入 userId 列表(最多 20 人)、可选的假期名称列表(不填查全部)和不超过 32 天的时间区间,返回各假期类型的时长数据。仅管理员可用。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标用户 userId 列表,逗号分隔,最多 20 人", Required: true}, + {Name: "leave-names", Type: shortcut.FlagStringSlice, Desc: "假期类型名称列表,逗号分隔,不填则查询所有假期类型"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度不超过 32 天", Required: true}, + }, + Tips: []string{`dws attendance +query-report-leave --users userId1 --leave-names 年假,病假 --start "2026-03-01 00:00:00" --end "2026-03-31 23:59:59"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + startStr := rt.Str("start") + endStr := rt.Str("end") + if _, err := time.ParseInLocation("2006-01-02 15:04:05", startStr, time.Local); err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if _, err := time.ParseInLocation("2006-01-02 15:04:05", endStr, time.Local); err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + return rt.CallMCP("get_leave_time_by_leave_names", map[string]any{ + "McpLeaveQueryParam": map[string]any{ + "targetUserIds": rt.StrSlice("users"), + "leaveNames": rt.StrSlice("leave-names"), + "fromDate": startStr, + "toDate": endStr, + }, + }) + }, +} + +// ── vacation ──────────────────────────────────────────────── + +// ListLeaveTypes 查询当前用户假期规则列表(get_leave_types)。 +var ListLeaveTypes = shortcut.Shortcut{ + Service: "attendance", + Command: "+list-leave-types", + Product: serverWukong, + Description: "查询当前用户可用的假期规则列表", + Intent: "当你想知道企业有哪些假期类型(年假、事假、病假等)及其对应的假期编码 code、单位、是否带薪时使用,通常是查余额或改假期规则前先拿到 leaveCode;无需参数,返回当前用户可用的假期规则列表。", + Risk: shortcut.RiskRead, + Tips: []string{`dws attendance +list-leave-types`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_leave_types", map[string]any{ + "McpLeaveTypeRequest": map[string]any{}, + }) + }, +} + +// GetLeaveBalance 查询指定员工假期余额(get_leave_balance_quota)。 +var GetLeaveBalance = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-leave-balance", + Product: serverWukong, + Description: "查询指定员工的假期余额", + Intent: "当你想知道某些员工当前还剩多少假(如还有几天年假可用)时使用;输入 userId 列表,可选传假期规则 code 只查某类假(不传则返回所有假期类型的余额),返回各员工的假期剩余额度。假期 code 可用 +list-leave-types 获取。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标员工 userId 列表,逗号分隔", Required: true}, + {Name: "leave-code", Type: shortcut.FlagString, Desc: "假期规则 code(不传则查询所有假期)"}, + }, + Tips: []string{`dws attendance +get-leave-balance --users userId1,userId2 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890`}, + Execute: func(rt *shortcut.RuntimeContext) error { + req := map[string]any{ + "targetUserIds": rt.StrSlice("users"), + } + if v := rt.Str("leave-code"); v != "" { + req["leaveCode"] = v + } + return rt.CallMCP("get_leave_balance_quota", map[string]any{ + "McpLeaveBalanceRequest": req, + }) + }, +} + +// GetLeaveRecords 查询指定员工假期余额变更记录(get_leave_balance_records_v2)。 +var GetLeaveRecords = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-leave-records", + Product: serverWukong, + Description: "查询指定员工的假期余额变更记录", + Intent: "当你想追溯某个员工假期额度的变动流水(何时发放、扣减、因请假消耗多少)以核对余额来龙去脉时使用;输入单个 userId、日期区间,可选假期 code(不传查所有假期),返回该员工在该时间段的余额变更记录。只想看当前余额用 +get-leave-balance。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "user", Type: shortcut.FlagString, Desc: "目标员工 userId", Required: true}, + {Name: "leave-code", Type: shortcut.FlagString, Desc: "假期规则 code(不传则查询所有假期)"}, + {Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true}, + }, + Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`}, + Execute: func(rt *shortcut.RuntimeContext) error { + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + req := map[string]any{ + "targetUserId": rt.Str("user"), + "startTimeStamp": start, + "endTimeStamp": end, + } + if v := rt.Str("leave-code"); v != "" { + req["leaveCode"] = v + } + return rt.CallMCP("get_leave_balance_records_v2", map[string]any{ + "McpLeaveRecordRequest": req, + }) + }, +} + +// UpdateLeaveType 更新假期规则(save_leave_type)。 +var UpdateLeaveType = shortcut.Shortcut{ + Service: "attendance", + Command: "+update-leave-type", + Product: serverWukong, + Description: "更新已有假期规则(仅传要修改的字段)", + Intent: "当管理员要修改某类假期的规则设置(改名称、假期单位、是否带薪、一天折算小时数、新员工可请假时机、适用范围等)时使用;输入假期编码 leaveCode 和需要变更的字段,会实际更新该假期规则,影响全体适用员工的请假规则。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "leave-code", Type: shortcut.FlagString, Desc: "假期编码", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "假期名称"}, + {Name: "unit", Type: shortcut.FlagString, Enum: []string{"day", "halfDay", "hour"}, Desc: "假期单位:day/halfDay/hour"}, + {Name: "paid", Type: shortcut.FlagBool, Desc: "是否带薪假期"}, + {Name: "per-hours", Type: shortcut.FlagInt, Desc: "一天折算小时数"}, + {Name: "when-can-leave", Type: shortcut.FlagString, Enum: []string{"entry", "formal"}, Desc: "新员工请假规则:entry/formal"}, + {Name: "visibility-rules", Type: shortcut.FlagString, Desc: `适用范围规则 JSON 数组,如 [{"type":"dept","visible":["1","2"]}];全公司可见传 [{"type":"dept","visible":["-1"]}]`}, + }, + Tips: []string{`dws attendance +update-leave-type --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --name "事假(修改版)"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + req := map[string]any{ + "leaveCode": rt.Str("leave-code"), + } + changed := 0 + if rt.Changed("name") { + req["leaveName"] = rt.Str("name") + changed++ + } + if rt.Changed("unit") { + req["leaveUnit"] = rt.Str("unit") + changed++ + } + if rt.Changed("paid") { + req["paidLeave"] = rt.Bool("paid") + changed++ + } + if rt.Changed("per-hours") { + if v := rt.Int("per-hours"); v > 0 { + req["perHoursInDay"] = v + } + changed++ + } + if rt.Changed("when-can-leave") { + if v := rt.Str("when-can-leave"); v != "" { + req["whenCanLeave"] = v + } + changed++ + } + if rt.Changed("visibility-rules") { + raw := strings.TrimSpace(rt.Str("visibility-rules")) + if raw != "" { + var rules []map[string]any + if err := json.Unmarshal([]byte(raw), &rules); err != nil { + return fmt.Errorf("invalid --visibility-rules JSON: %w", err) + } + if len(rules) == 0 { + return fmt.Errorf("--visibility-rules 不能是空数组,如需改为全公司可见请显式传 [{\"type\":\"dept\",\"visible\":[\"-1\"]}]") + } + req["visibilityRules"] = rules + } + changed++ + } + if changed == 0 { + return fmt.Errorf("至少需要指定一个更新字段:--name / --unit / --paid / --per-hours / --when-can-leave / --visibility-rules") + } + return rt.CallMCP("save_leave_type", map[string]any{ + "McpSaveLeaveTypeRequest": req, + }) + }, +} + +// SaveLeaveBalance 设置员工假期余额(update_leave_balance)。 +var SaveLeaveBalance = shortcut.Shortcut{ + Service: "attendance", + Command: "+save-leave-balance", + Product: serverWukong, + Description: "设置员工假期余额(SET 覆盖,非累加)", + Intent: "当管理员要给某员工发放或调整某类假期的余额(如年度发放年假、手工修正额度)时使用;输入员工 userId、假期编码、目标数量和变更原因,可选有效期。注意这是 SET 覆盖写入(把余额直接设为该数量而非在原基础上累加),会实际改变员工可用假期,操作前务必确认数量。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "target", Type: shortcut.FlagString, Desc: "目标员工工号 userId", Required: true}, + {Name: "leave-code", Type: shortcut.FlagString, Desc: "假期编码", Required: true}, + {Name: "num", Type: shortcut.FlagString, Desc: "余额数量(如 8 天传 8,7.5 天传 7.5)", Required: true}, + {Name: "reason", Type: shortcut.FlagString, Desc: "变更原因,最长 100 字符", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "有效期开始日期 YYYY-MM-DD"}, + {Name: "end", Type: shortcut.FlagString, Desc: "有效期结束日期 YYYY-MM-DD"}, + }, + Tips: []string{`dws attendance +save-leave-balance --target user001 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --num 8 --reason "年度发放"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + numFloat, err := strconv.ParseFloat(rt.Str("num"), 64) + if err != nil { + return fmt.Errorf("--num 格式错误: %w(应为数字,如 8 或 7.5)", err) + } + req := map[string]any{ + "targetUserId": rt.Str("target"), + "leaveCode": rt.Str("leave-code"), + "quotaNum": strconv.Itoa(int(numFloat * 100)), + "reason": rt.Str("reason"), + } + if v := rt.Str("start"); v != "" { + ts, err := dateToMillis(v, false) + if err != nil { + return err + } + req["startTime"] = ts + } + if v := rt.Str("end"); v != "" { + ts, err := dateToMillis(v, true) + if err != nil { + return err + } + req["endTime"] = ts + } + return rt.CallMCP("update_leave_balance", map[string]any{ + "McpUpdateLeaveBalanceRequest": req, + }) + }, +} + +// ── checkin ───────────────────────────────────────────────── + +// GetCheckinRecord 查询指定员工的签到记录(get_checkin_record)。 +var GetCheckinRecord = shortcut.Shortcut{ + Service: "attendance", + Command: "+get-checkin-record", + Product: serverWukong, + Description: "查询指定员工一段时间内的签到记录", + Intent: "当你要查看员工的外勤/移动办公签到记录(signin/checkin,区别于考勤打卡)时使用,例如核实业务员的拜访签到轨迹;需提供操作者企业 ID 与员工 ID、目标员工 ID 列表(最多 100 人)以及不超过 7 天的时间区间,返回这些人的签到明细。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "operator-corp-id", Type: shortcut.FlagString, Desc: "操作者企业 ID", Required: true}, + {Name: "operator-staff-id", Type: shortcut.FlagString, Desc: "操作者员工 ID", Required: true}, + {Name: "staff-ids", Type: shortcut.FlagStringSlice, Desc: "目标员工 ID 列表,逗号分隔,最多 100 人", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true}, + }, + Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + startStr := rt.Str("start") + endStr := rt.Str("end") + startT, err := time.ParseInLocation("2006-01-02 15:04:05", startStr, time.Local) + if err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + endT, err := time.ParseInLocation("2006-01-02 15:04:05", endStr, time.Local) + if err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if endT.Before(startT) { + return fmt.Errorf("--end 不能早于 --start") + } + return rt.CallMCP("get_checkin_record", map[string]any{ + "QueryMcpUserRecordRequest": map[string]any{ + "operatorCorpId": rt.Str("operator-corp-id"), + "operatorStaffId": rt.Str("operator-staff-id"), + "staffIds": rt.StrSlice("staff-ids"), + "startTime": startStr, + "endTime": endStr, + }, + }) + }, +} + +// ── boss check ────────────────────────────────────────────── + +// BossCheck BOSS 改签打卡记录(boss_check)。 +var BossCheck = shortcut.Shortcut{ + Service: "attendance", + Command: "+boss-check", + Product: serverWukong, + Description: "BOSS 改签打卡记录(管理员修改打卡时间/结果)", + Intent: "当管理员/主管要手工修正某次打卡(改打卡时间、把结果改成正常或指定迟到/早退/缺卡等)时使用,例如员工漏打卡或异常需纠正;需提供排班 planId 或打卡结果 resultId(二选一,可由 +get-schedule 获取),并指定新时间/结果/缺勤分钟/备注。会实际改写该员工的考勤结果,属敏感操作。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "plan-id", Type: shortcut.FlagString, Desc: "排班 ID(与 --result-id 二选一,可由 +get-schedule 获取 id)"}, + {Name: "result-id", Type: shortcut.FlagString, Desc: "打卡结果 ID(与 --plan-id 二选一,优先使用)"}, + {Name: "time", Type: shortcut.FlagString, Desc: "新打卡时间 yyyy-MM-dd HH:mm"}, + {Name: "result", Type: shortcut.FlagString, Enum: []string{"Normal", "TimesResultA", "TimesResultB", "TimesResultC", "TimesResultD", "TimesResultE", "TimesResultF"}, Desc: "打卡结果:Normal/迟到 A/早退 B/缺卡 C/迟到+早退 D/缺卡+早退 E/迟到+缺卡 F"}, + {Name: "absent-min", Type: shortcut.FlagInt, Desc: "缺勤时长(分钟),异常结果时传值"}, + {Name: "remark", Type: shortcut.FlagString, Desc: "备注,最长 500 字符"}, + }, + Tips: []string{`dws attendance +boss-check --plan-id 123456 --time "2026-04-21 08:30"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("plan-id") == "" && rt.Str("result-id") == "" { + return fmt.Errorf("--plan-id 和 --result-id 至少传一个") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + req := map[string]any{} + if resultID := rt.Str("result-id"); resultID != "" { + req["resultId"] = resultID + } else if planID := rt.Str("plan-id"); planID != "" { + req["planId"] = planID + } + if rt.Changed("time") { + req["bossCheckTime"] = rt.Str("time") + } + if rt.Changed("result") { + req["result"] = rt.Str("result") + } + if rt.Changed("absent-min") { + req["absentMin"] = rt.Int("absent-min") + } + if rt.Changed("remark") { + req["remark"] = rt.Str("remark") + } + return rt.CallMCP("boss_check", map[string]any{ + "BossCheckMcpRequest": req, + }) + }, +} + +func init() { + shortcut.Register( + CheckResult, + CheckRecord, + ListApprove, + GetApproveTemplate, + GetSchedule, + ImportSchedule, + SearchClass, + GetClass, + CreateClass, + UpdateClass, + GetAdjustmentRule, + SearchAdjustmentRule, + GetOvertimeRule, + SearchOvertimeRule, + SearchGroup, + GetGroup, + GetGroupFiltered, + UpdateGroupMembers, + CreateGroup, + UpdateGroup, + GetSummary, + GetSelfSetting, + GetGlobalSetting, + ListReportColumns, + QueryReportData, + QueryReportLeave, + ListLeaveTypes, + GetLeaveBalance, + GetLeaveRecords, + UpdateLeaveType, + SaveLeaveBalance, + GetCheckinRecord, + BossCheck, + ) +} diff --git a/internal/shortcut/calendar/calendar.go b/internal/shortcut/calendar/calendar.go new file mode 100644 index 00000000..6d07ac8f --- /dev/null +++ b/internal/shortcut/calendar/calendar.go @@ -0,0 +1,733 @@ +// 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 calendar declares the declarative shortcut (+command) layer for the +// DingTalk calendar MCP product. Tool names and parameter keys are copied +// verbatim from internal/helpers/calendar.go, the single source of truth for +// the real DingTalk MCP tools. +package calendar + +import ( + "fmt" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// parseMillis converts an ISO-8601 / RFC3339 timestamp (e.g. +// "2026-03-10T14:00:00+08:00") into Unix epoch milliseconds, matching the +// helper's parseISOTimeToMillis behaviour for the millis-based MCP tools. +func parseMillis(field, v string) (int64, error) { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return 0, fmt.Errorf("invalid --%s time %q: expected ISO-8601 like 2026-03-10T14:00:00+08:00", field, v) + } + return t.UnixMilli(), nil +} + +// ── event: 日程 ────────────────────────────────────────────── + +// EventList → list_calendar_events +var EventList = shortcut.Shortcut{ + Service: "calendar", + Command: "+agenda", + Product: "calendar", + Description: "查询日程列表(不传时间默认查询今天)", + Intent: "当你想了解某人(默认自己)在某段时间内的日程安排、看看今天/本周有哪些会时使用;可传 --start/--end 圈定时间范围、--calendar-id 指定日历,返回该区间内的日程列表(含日程 ID,可配合 +get 看详情)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00),默认今天 00:00"}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601,默认今天 23:59"}, + {Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一次返回的 nextCursor)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数 (默认 100,最大 100)"}, + }, + Tips: []string{ + `dws calendar +agenda`, + `dws calendar +agenda --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --limit 50`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + now := time.Now() + if rt.Changed("start") { + ms, err := parseMillis("start", rt.Str("start")) + if err != nil { + return err + } + params["startTime"] = ms + } else { + params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli() + } + if rt.Changed("end") { + ms, err := parseMillis("end", rt.Str("end")) + if err != nil { + return err + } + params["endTime"] = ms + } else { + params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli() + } + if rt.Changed("calendar-id") { + params["calendarId"] = rt.Str("calendar-id") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + if rt.Changed("limit") { + params["limit"] = rt.Int("limit") + } + data, err := rt.CallMCPData("calendar", "list_calendar_events", params) + if err != nil { + return err + } + events := eventListProject(data) + return rt.Output(map[string]any{"count": len(events), "events": events}) + }, +} + +// eventListProject reshapes the raw list_calendar_events response into a clean, +// stable event list (eventId/summary/start/end/status/location) — the +// the clean output projection applied to every list command. The +// list container and each field are probed defensively across candidate keys, +// since event payloads may nest under result/data/list/items with aliases. +func eventListProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := eventListContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := eventListFirst(m, "eventId", "event_id", "id"); ok { + row["eventId"] = v + } + if v, ok := eventListFirst(m, "summary", "title", "name"); ok { + row["summary"] = v + } + if v, ok := eventListFirst(m, "start", "startTime", "start_time", "startDateTime", "start_date_time"); ok { + row["start"] = v + } + if v, ok := eventListFirst(m, "end", "endTime", "end_time", "endDateTime", "end_date_time"); ok { + row["end"] = v + } + if v, ok := eventListFirst(m, "status", "eventStatus", "event_status", "responseStatus", "response_status"); ok { + row["status"] = v + } + if v, ok := eventListFirst(m, "location", "locationName", "location_name"); ok { + row["location"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// eventListContainer locates the event slice across candidate wrapper keys, +// unwrapping one nested object layer (e.g. result.list) when needed. +func eventListContainer(data map[string]any) []any { + keys := []string{"result", "data", "list", "items", "events"} + for _, k := range keys { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if nested, ok := v.(map[string]any); ok { + for _, nk := range keys { + if arr, ok := nested[nk].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// eventListFirst returns the first present, non-nil value among candidate keys. +func eventListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v, true + } + } + return nil, false +} + +// EventGet → get_calendar_detail +// EventCreate → create_calendar_event +// EventUpdate → update_calendar_event +// EventDelete → delete_calendar_event +// EventSuggest → list_suggested_event_times +// EventRespond → respond +// ── attendee: 参会人 ────────────────────────────────────────── + +// AttendeeList → get_calendar_participants +var AttendeeList = shortcut.Shortcut{ + Service: "calendar", + Command: "+attendee-list", + Product: "calendar", + Description: "查看日程参会人", + Intent: "当你想知道某个日程都有谁参加、各人的出席响应状态时使用;输入 --event 日程 ID,返回参会人列表(userId 及其接受/拒绝等状态)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "event", Type: shortcut.FlagString, Desc: "日程 ID", Required: true}, + {Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"}, + }, + Tips: []string{`dws calendar +attendee-list --event EVENT_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"eventId": rt.Str("event")} + if rt.Changed("calendar-id") { + params["calendarId"] = rt.Str("calendar-id") + } + data, err := rt.CallMCPData("calendar", "get_calendar_participants", params) + if err != nil { + return err + } + attendees := attendeeListProject(data) + return rt.Output(map[string]any{"count": len(attendees), "attendees": attendees}) + }, +} + +// attendeeListProject reshapes the raw get_calendar_participants response into a +// clean, stable attendee list (displayName/userId/responseStatus) — the +// the clean output projection applied to every list command. +// The list container and each field are probed defensively across candidate keys, +// since participant payloads may nest under result/data/list/items with aliases. +func attendeeListProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := attendeeListContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := attendeeFirst(m, "displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"); ok { + row["displayName"] = v + } + if v, ok := attendeeFirst(m, "userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"); ok { + row["userId"] = v + } + if v, ok := attendeeFirst(m, "responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"); ok { + row["responseStatus"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// attendeeListContainer locates the participant slice across candidate wrapper +// keys, unwrapping one nested object layer (e.g. result.list) when needed. +func attendeeListContainer(data map[string]any) []any { + keys := []string{"result", "data", "list", "items", "attendees", "participants"} + for _, k := range keys { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if nested, ok := v.(map[string]any); ok { + for _, nk := range keys { + if arr, ok := nested[nk].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// attendeeFirst returns the first present, non-nil value among candidate keys. +func attendeeFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v, true + } + } + return nil, false +} + +// AttendeeAdd → add_calendar_participant +// AttendeeRemove → remove_calendar_participant +// ── room: 会议室 ────────────────────────────────────────────── + +// RoomSearch → search_rooms (按名称模糊搜索,不检查可用性) +var RoomSearch = shortcut.Shortcut{ + Service: "calendar", + Command: "+room-search", + Product: "calendar", + Description: "按名称模糊搜索会议室(不检查可用性)", + Intent: "当你只知道会议室名字、想拿到它的 roomId 以便后续预定时使用;输入 --room-name 名称关键词(建议只填核心专名,去掉“会议室”等后缀),返回名称匹配的会议室列表。它只按名字找、不判断该时段是否空闲,查可用性请用 +room-find。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称(精简核心专名,剔除“会议室”等后缀)", Required: true}, + }, + Tips: []string{`dws calendar +room-search --room-name 永澄亭`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"roomName": strings.TrimSpace(rt.Str("room-name"))} + data, err := rt.CallMCPData("calendar", "search_rooms", params) + if err != nil { + return err + } + rooms := roomSearchProject(data) + return rt.Output(map[string]any{"count": len(rooms), "rooms": rooms}) + }, +} + +// roomSearchProject reshapes the raw search_rooms response into a clean, stable +// room list (roomId/roomName/capacity/location) — the output-projection fidelity +// the framework applies to every list command. The list container and each field +// are probed defensively across candidate keys, since room payloads may nest +// under result/data/list/items with aliases. +func roomSearchProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := roomSearchContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := roomSearchFirst(m, "roomId", "room_id", "id"); ok { + row["roomId"] = v + } + if v, ok := roomSearchFirst(m, "roomName", "room_name", "name", "summary"); ok { + row["roomName"] = v + } + if v, ok := roomSearchFirst(m, "capacity", "seats", "seatCount", "seat_count"); ok { + row["capacity"] = v + } + if v, ok := roomSearchFirst(m, "location", "floor", "building", "address"); ok { + row["location"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// roomSearchContainer locates the room slice across candidate wrapper keys, +// unwrapping one nested object layer (e.g. result.list) when needed. +func roomSearchContainer(data map[string]any) []any { + keys := []string{"result", "data", "list", "items", "rooms"} + for _, k := range keys { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if nested, ok := v.(map[string]any); ok { + for _, nk := range keys { + if arr, ok := nested[nk].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// roomSearchFirst returns the first present, non-nil value among candidate keys. +func roomSearchFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v, true + } + } + return nil, false +} + +// RoomFind → query_available_meeting_room (按时间段查可用会议室) +var RoomFind = shortcut.Shortcut{ + Service: "calendar", + Command: "+room-find", + Product: "calendar", + Description: "按时间段搜索可用会议室(不传时间默认当前起 1 小时)", + Intent: "当你要在某个时间段找一间空闲会议室开会时使用;传入 --start/--end 时间段(须为未来时间,缺省为当前起 1 小时),可加 --available 只看空闲、--group-id/--room-name 缩小范围,返回该时段的会议室及其可用性和 roomId,便于据此 +room-add 预定。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (必须是未来时间)"}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601"}, + {Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室"}, + {Name: "group-id", Type: shortcut.FlagString, Desc: "会议室分组 ID"}, + {Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称过滤"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"}, + {Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"}, + }, + Tips: []string{ + `dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + startStr := rt.Str("start") + endStr := rt.Str("end") + if startStr == "" { + startStr = now.Add(1 * time.Minute).Format(time.RFC3339) + } + if endStr == "" { + endStr = now.Add(1 * time.Hour).Format(time.RFC3339) + } + startMs, err := parseMillis("start", startStr) + if err != nil { + return err + } + endMs, err := parseMillis("end", endStr) + if err != nil { + return err + } + params := map[string]any{ + "startTime": startMs, + "endTime": endMs, + } + if rt.Bool("available") { + params["needAvailable"] = true + } + if rt.Changed("group-id") { + params["groupId"] = rt.Str("group-id") + } + if rt.Changed("room-name") { + params["roomName"] = strings.TrimSpace(rt.Str("room-name")) + } + if rt.Changed("limit") { + params["pageSize"] = rt.Str("limit") + } + if rt.Changed("page") { + params["pageIndex"] = rt.Str("page") + } + return rt.CallMCP("query_available_meeting_room", params) + }, +} + +// RoomAdd → add_meeting_room +// RoomRemove → delete_meeting_room +// RoomGroups → list_meeting_room_groups +var RoomGroups = shortcut.Shortcut{ + Service: "calendar", + Command: "+room-groups", + Product: "calendar", + Description: "会议室分组列表", + Intent: "当你想按楼层/园区等分组浏览会议室、或需要拿到 groupId 以便在 +room-find 里按分组过滤时使用;返回会议室分组列表,支持 --limit/--page 分页。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"}, + {Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"}, + }, + Tips: []string{`dws calendar +room-groups`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("limit") { + params["pageSize"] = rt.Str("limit") + } + if rt.Changed("page") { + params["pageIndex"] = rt.Str("page") + } + data, err := rt.CallMCPData("calendar", "list_meeting_room_groups", params) + if err != nil { + return err + } + groups := roomGroupsProject(data) + return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + }, +} + +// roomGroupsProject reshapes the raw list_meeting_room_groups response into a +// clean, stable group list (groupId/groupName) — the output-projection fidelity +// the framework applies to every list command. The list container and each field +// are probed defensively across candidate keys, since group payloads may nest +// under result/data/list/items with aliases. +func roomGroupsProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := roomGroupsContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := roomGroupsFirst(m, "groupId", "group_id", "id"); ok { + row["groupId"] = v + } + if v, ok := roomGroupsFirst(m, "groupName", "group_name", "name", "summary"); ok { + row["groupName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// roomGroupsContainer locates the group slice across candidate wrapper keys, +// unwrapping one nested object layer (e.g. result.list) when needed. +func roomGroupsContainer(data map[string]any) []any { + keys := []string{"result", "data", "list", "items", "groups"} + for _, k := range keys { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if nested, ok := v.(map[string]any); ok { + for _, nk := range keys { + if arr, ok := nested[nk].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// roomGroupsFirst returns the first present, non-nil value among candidate keys. +func roomGroupsFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v, true + } + } + return nil, false +} + +// ── busy: 闲忙 ──────────────────────────────────────────────── + +// BusySearch → query_busy_status +var BusySearch = shortcut.Shortcut{ + Service: "calendar", + Command: "+freebusy", + Product: "calendar", + Description: "查询用户 / 会议室闲忙状态(--users 与 --rooms 至少其一)", + Intent: "当你要在约会前确认某些人或会议室在指定时间段是否有空、避免冲突时使用;传入 --start/--end 时间段并至少给出 --users 或 --rooms 其一,返回各对象在该区间的忙/闲时段。只看忙闲结果、不看具体日程内容,需要系统给出建议时段可用 +suggestion。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表 (逗号分隔)"}, + {Name: "rooms", Type: shortcut.FlagStringSlice, Desc: "会议室 roomId 列表 (逗号分隔)"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601", Required: true}, + }, + Tips: []string{ + `dws calendar +freebusy --users userId1,userId2 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if len(rt.StrSlice("users")) == 0 && len(rt.StrSlice("rooms")) == 0 { + return fmt.Errorf("--users 与 --rooms 至少指定其一") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + startMs, err := parseMillis("start", rt.Str("start")) + if err != nil { + return err + } + endMs, err := parseMillis("end", rt.Str("end")) + if err != nil { + return err + } + params := map[string]any{ + "startTime": startMs, + "endTime": endMs, + } + if len(rt.StrSlice("users")) > 0 { + params["userIds"] = rt.StrSlice("users") + } + if len(rt.StrSlice("rooms")) > 0 { + params["roomIds"] = rt.StrSlice("rooms") + } + return rt.CallMCP("query_busy_status", params) + }, +} + +// ── attachment: 附件 ────────────────────────────────────────── + +// AttachmentAdd → add_attachments +// ── acl: 日历访问权限 ───────────────────────────────────────── + +// AclList → list_acls +// AclAdd → add_acl +// AclDelete → delete_acl +// ── book: 日历本 ────────────────────────────────────────────── + +// BookList → list_calendars +var BookList = shortcut.Shortcut{ + Service: "calendar", + Command: "+book-list", + Product: "calendar", + Description: "查询用户的日历本列表", + Intent: "当你想知道自己有哪些日历本(主日历、项目日历、订阅日历等)、或需要拿到某个日历的 calendarId 以便在 +agenda/+create 中指定时使用;无需参数,返回全部日历本列表。", + Risk: shortcut.RiskRead, + Tips: []string{`dws calendar +book-list`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("calendar", "list_calendars", nil) + if err != nil { + return err + } + books := bookListProject(data) + return rt.Output(map[string]any{"count": len(books), "calendars": books}) + }, +} + +// bookListProject reshapes list_calendars into a clean calendar-book list +// (calendarId/summary/privilege/type) — clean output projection. +func bookListProject(data map[string]any) []map[string]any { + raw, ok := data["result"].([]any) + if !ok { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + for _, k := range []string{"calendarId", "summary", "privilege", "type", "description"} { + if v, ok := m[k]; ok { + row[k] = v + } + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// BookGet → get_calendar +// BookSearch → search_calendar +var BookSearch = shortcut.Shortcut{ + Service: "calendar", + Command: "+book-search", + Product: "calendar", + Description: "按名称模糊搜索日历本", + Intent: "当你只记得日历本名字的一部分、想据此找到对应的 calendarId 时使用;输入 --query 名称关键词,返回名称匹配的日历本列表,便于后续在其它命令里指定 --calendar-id。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "日历本名称关键词", Required: true}, + }, + Tips: []string{`dws calendar +book-search --query "项目"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("calendar", "search_calendar", map[string]any{"query": rt.Str("query")}) + if err != nil { + return err + } + calendars := bookSearchProject(data) + return rt.Output(map[string]any{"count": len(calendars), "calendars": calendars}) + }, +} + +// bookSearchProject reshapes the raw search_calendar response into a clean, +// stable calendar-book list (calendarId/summary/privilege/type) — output-projection +// clean output projection. The list container and each field are probed defensively +// across candidate keys, tolerating nesting under result/data/list/items. +func bookSearchProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := bookSearchContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := bookSearchFirst(m, "calendarId", "calendar_id", "id"); ok { + row["calendarId"] = v + } + if v, ok := bookSearchFirst(m, "summary", "name", "title"); ok { + row["summary"] = v + } + if v, ok := bookSearchFirst(m, "privilege", "role", "accessRole"); ok { + row["privilege"] = v + } + if v, ok := bookSearchFirst(m, "type", "calendarType", "calendar_type"); ok { + row["type"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// bookSearchContainer locates the calendar slice across candidate wrapper keys, +// unwrapping one nested object layer (e.g. result.list) when needed. +func bookSearchContainer(data map[string]any) []any { + keys := []string{"result", "data", "list", "items", "calendars"} + for _, k := range keys { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if nested, ok := v.(map[string]any); ok { + for _, nk := range keys { + if arr, ok := nested[nk].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// bookSearchFirst returns the first present, non-nil value among candidate keys. +func bookSearchFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v, true + } + } + return nil, false +} + +// BookUpdate → update_calendar +func init() { + shortcut.Register( + EventList, + AttendeeList, + RoomSearch, + RoomFind, + RoomGroups, + BusySearch, + BookList, + BookSearch, + ) +} diff --git a/internal/shortcut/chat/chat.go b/internal/shortcut/chat/chat.go new file mode 100644 index 00000000..148a7f38 --- /dev/null +++ b/internal/shortcut/chat/chat.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package chat provides declarative shortcuts for the DingTalk chat service +// (群聊 / 会话 / 消息 / 机器人). Tool names and parameter keys are copied verbatim +// from internal/helpers/chat.go, the single source of truth for the real MCP +// tools. Shortcuts route to the correct MCP server via each Shortcut.Product: +// - "chat" (default): tools invoked via the plain helper callMCPTool path +// - "im": tools invoked via callMCPToolOnServer("im", ...) +// - "bot": tools invoked via callMCPToolOnServer("bot", ...) +package chat + +import ( + "fmt" + "strconv" + "strings" +) + +// isOpenID reports whether value looks like an openDingTalkId (starts with D/d), +// mirroring helpers.isOpenDingTalkID. +func isOpenID(v string) bool { + v = strings.TrimSpace(v) + return len(v) > 0 && (v[0] == 'D' || v[0] == 'd') +} + +// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring +// helpers.splitChatIDValues. +func splitIDs(vals []string) (userIDs, openIDs []string) { + for _, raw := range vals { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + if isOpenID(v) { + openIDs = append(openIDs, v) + } else { + userIDs = append(userIDs, v) + } + } + return userIDs, openIDs +} + +// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64. +func toInt64Slice(vals []string) ([]int64, error) { + out := make([]int64, 0, len(vals)) + for _, raw := range vals { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer %q", v) + } + out = append(out, n) + } + if len(out) == 0 { + return nil, fmt.Errorf("at least one ID is required") + } + return out, nil +} diff --git a/internal/shortcut/chat/chat_bot.go b/internal/shortcut/chat/chat_bot.go new file mode 100644 index 00000000..1caf344b --- /dev/null +++ b/internal/shortcut/chat/chat_bot.go @@ -0,0 +1,179 @@ +// 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 chat + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// BotSearch searches robots created by the current user (search_my_robots, bot). +var BotSearch = shortcut.Shortcut{ + Service: "chat", + Command: "+bot-search", + Product: "bot", + Description: "搜索当前用户自己创建的机器人", + Intent: "当你要管理或复用自己创建的机器人(比如查到其 robotCode 以便让它进群或发消息)时使用;按机器人名称模糊搜索,只返回当前用户名下创建的机器人列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码"}, + {Name: "size", Type: shortcut.FlagInt, Desc: "每页数量"}, + {Name: "name", Type: shortcut.FlagString, Desc: "robotName 模糊匹配"}, + }, + Tips: []string{`dws chat +bot-search --page 1 --name "日报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"currentPage": rt.Int("page")} + if rt.Int("size") > 0 { + params["pageSize"] = rt.Int("size") + } + if rt.Str("name") != "" { + params["robotName"] = rt.Str("name") + } + data, err := rt.CallMCPData("bot", "search_my_robots", params) + if err != nil { + return err + } + robots := botSearchProject(data) + return rt.Output(map[string]any{"count": len(robots), "robots": robots}) + }, +} + +// botSearchProject reshapes the raw search_my_robots response into a clean robot +// list (robotCode/robotName/status) — clean output projection. The +// list container and field names are probed defensively across candidate keys. +func botSearchProject(data map[string]any) []map[string]any { + raw := botResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := botFirst(m, "robotCode", "robot_code", "code", "id"); ok { + row["robotCode"] = v + } + if v, ok := botFirst(m, "robotName", "robot_name", "name"); ok { + row["robotName"] = v + } + if v, ok := botFirst(m, "status", "robotStatus", "state"); ok { + row["status"] = v + } + if v, ok := botFirst(m, "gmtCreate", "createTime", "create_time"); ok { + row["gmtCreate"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// botResolveList locates the list payload inside the response, tolerating a bare +// top-level array or nesting under result/data/list/items containers. +func botResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "robots", "records"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "robots", "records", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// botFirst returns the first present candidate key's value. +func botFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// BotFind searches all available robots (search_bots, bot). +var BotFind = shortcut.Shortcut{ + Service: "chat", + Command: "+bot-find", + Product: "bot", + Description: "搜索全部可用机器人(含他人/官方,返回 openDingTalkId 可发单聊)", + Intent: "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"}, + }, + Tips: []string{`dws chat +bot-find --query "日报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"keyword": rt.Str("query")} + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if rt.Str("cursor") != "" { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("bot", "search_bots", params) + if err != nil { + return err + } + bots := botFindProject(data) + return rt.Output(map[string]any{"count": len(bots), "bots": bots}) + }, +} + +// botFindProject reshapes the raw search_bots response into a clean bot list +// (openDingTalkId/name/robotCode) — clean output projection. The +// list container and field names are probed defensively across candidate keys. +func botFindProject(data map[string]any) []map[string]any { + raw := botResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := botFirst(m, "openDingTalkId", "open_ding_talk_id", "openId"); ok { + row["openDingTalkId"] = v + } + if v, ok := botFirst(m, "name", "robotName", "botName"); ok { + row["name"] = v + } + if v, ok := botFirst(m, "robotCode", "robot_code", "code", "id"); ok { + row["robotCode"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server). +func init() { + shortcut.Register( + BotSearch, + BotFind, + ) +} diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go new file mode 100644 index 00000000..8e5df36a --- /dev/null +++ b/internal/shortcut/chat/chat_conversation.go @@ -0,0 +1,749 @@ +// 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 chat + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ConversationInfo gets conversation info (get_conversation_info, chat server). +var ConversationInfo = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-info", + Product: "chat", + Description: "获取会话信息(群聊传 --group,单聊传 --open-dingtalk-id)", + Intent: "当你已有群 openConversationId 或单聊对方 openDingTalkId、需要查看该会话的名称/类型/成员数等基础信息时使用;只读,群聊传 --group、单聊传 --open-dingtalk-id 二选一。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群聊 openConversationId"}, + {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方 openDingTalkId"}, + }, + Tips: []string{`dws chat +conversation-info --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Str("group") != "" { + params["openConversationId"] = rt.Str("group") + } + if rt.Str("open-dingtalk-id") != "" { + params["openDingTalkId"] = rt.Str("open-dingtalk-id") + } + if len(params) == 0 { + return fmt.Errorf("--group 或 --open-dingtalk-id 必填其一") + } + return rt.CallMCP("get_conversation_info", params) + }, +} + +// ConversationSetTop sets/unsets a conversation top (set_top_conversation, im). +var ConversationSetTop = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-set-top", + Product: "im", + Description: "会话置顶 / 取消置顶(支持单聊/群聊)", + Intent: "当你想把某个单聊或群聊置顶到会话列表顶部、或取消其置顶时使用;会实际修改该会话的置顶状态,需传 openConversationId,加 --off 取消置顶。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"}, + }, + Tips: []string{`dws chat +conversation-set-top --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_top_conversation", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "top": !rt.Bool("off"), + }) + }, +} + +// ConversationMute mutes/unmutes a conversation (update_notification_off, im). +var ConversationMute = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-mute", + Product: "im", + Description: "会话消息免打扰(支持单聊/群聊)", + Intent: "当你想对某个会话开启或关闭消息免打扰时使用;会实际更改该会话的免打扰设置,需传 openConversationId,加 --off 表示关闭免打扰。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "关闭免打扰(不传则开启免打扰)"}, + }, + Tips: []string{`dws chat +conversation-mute --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_notification_off", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "mute": !rt.Bool("off"), + }) + }, +} + +// ConversationMuteAtAll toggles @all notification (update_at_all_notification_off, im). +var ConversationMuteAtAll = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-mute-at-all", + Product: "im", + Description: "关闭/开启 @所有人消息提醒", + Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "恢复接收 @所有人通知(不传则关闭通知)"}, + }, + Tips: []string{`dws chat +conversation-mute-at-all --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_at_all_notification_off", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "mute": !rt.Bool("off"), + }) + }, +} + +// ConversationMuteRedEnvelope toggles red-envelope notification (update_red_env_notification_off, im). +var ConversationMuteRedEnvelope = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-mute-red-envelope", + Product: "im", + Description: "关闭/开启红包消息提醒", + Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "恢复接收红包通知(不传则关闭通知)"}, + }, + Tips: []string{`dws chat +conversation-mute-red-envelope --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_red_env_notification_off", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "mute": !rt.Bool("off"), + }) + }, +} + +// ConversationMarkUnread marks a conversation unread (mark_conversation_unread, im). +var ConversationMarkUnread = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-mark-unread", + Product: "im", + Description: "标记会话为未读", + Intent: "当你想把某个已读会话重新标记为未读(提醒自己稍后再处理)时使用;会实际改变该会话的未读状态,需传 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +conversation-mark-unread --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("mark_conversation_unread", map[string]any{"openConversationId": rt.Str("conversation-id")}) + }, +} + +// ConversationClearRedPoint clears a conversation red point (clear_conversation_red_point, im). +var ConversationClearRedPoint = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-clear-red-point", + Product: "im", + Description: "清除会话红点", + Intent: "当你想消除某个会话上的未读红点(小圆点)时使用;会实际清除该会话红点,需传 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +conversation-clear-red-point --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("clear_conversation_red_point", map[string]any{"openConversationId": rt.Str("conversation-id")}) + }, +} + +// ConversationClearAllRedPoint clears all red points (clear_all_red_point, im). +var ConversationClearAllRedPoint = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-clear-all-red-point", + Product: "im", + Description: "清除所有会话红点(全部已读)", + Intent: "当你想一键把全部会话标记为已读、清空所有红点时使用;会实际清除当前用户所有会话的红点,无需任何参数。", + Risk: shortcut.RiskWrite, + Tips: []string{`dws chat +conversation-clear-all-red-point`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("clear_all_red_point", map[string]any{}) + }, +} + +// ConversationList paginates all conversations (list_all_conversations, im). +var ConversationList = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-list", + Product: "im", + Description: "分页获取当前用户的全部会话列表(单聊+群聊)", + Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + }, + Tips: []string{`dws chat +conversation-list --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if rt.Int("cursor") > 0 { + params["cursor"] = rt.Int("cursor") + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("im", "list_all_conversations", params) + if err != nil { + return err + } + convs := conversationListProject(data) + payload := map[string]any{"count": len(convs), "conversations": convs} + // carry pagination hints when present so翻页仍可继续(字段防御式探测)。 + if v, ok := conversationListFirst(data, "nextCursor", "cursor"); ok { + payload["nextCursor"] = v + } + if v, ok := conversationListFirst(data, "hasMore", "has_more"); ok { + payload["hasMore"] = v + } + return rt.Output(payload) + }, +} + +// conversationListProject reshapes the raw list_all_conversations response into a +// clean conversation list — clean output projection. Both the list +// container and the per-item field names are probed defensively across candidate +// keys, so an unknown/empty shape yields an empty list rather than a crash or +// fabricated data. +func conversationListProject(data map[string]any) []map[string]any { + raw := conversationListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := conversationListFirst(m, "openConversationId", "conversationId", "id"); ok { + row["openConversationId"] = v + } + if v, ok := conversationListFirst(m, "conversationName", "name", "title"); ok { + row["conversationName"] = v + } + if v, ok := conversationListFirst(m, "conversationType", "type"); ok { + row["conversationType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// conversationListResolveList locates the conversation array inside the response, +// tolerating a bare top-level list or nesting one level under a common envelope. +func conversationListResolveList(data map[string]any) []any { + for _, key := range []string{"conversationList", "conversations", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// conversationListFirst returns the first present candidate key's value. +func conversationListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ConversationListTop lists pinned conversations (list_top_conversations, chat server). +var ConversationListTop = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-list-top", + Description: "拉取置顶会话列表", + Intent: "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + }, + Tips: []string{`dws chat +conversation-list-top --limit 1000`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if rt.Int("cursor") > 0 { + params["cursor"] = rt.Int("cursor") + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("chat", "list_top_conversations", params) + if err != nil { + return err + } + convs := conversationListTopProject(data) + payload := map[string]any{"count": len(convs), "conversations": convs} + if v, ok := conversationListTopFirst(data, "nextCursor", "cursor"); ok { + payload["nextCursor"] = v + } + if v, ok := conversationListTopFirst(data, "hasMore", "has_more"); ok { + payload["hasMore"] = v + } + return rt.Output(payload) + }, +} + +// conversationListTopProject reshapes the raw list_top_conversations response +// into a clean pinned-conversation list — clean output projection. +// Both the list container and the per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func conversationListTopProject(data map[string]any) []map[string]any { + raw := conversationListTopResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := conversationListTopFirst(m, "openConversationId", "conversationId", "id"); ok { + row["openConversationId"] = v + } + if v, ok := conversationListTopFirst(m, "conversationName", "name", "title"); ok { + row["conversationName"] = v + } + if v, ok := conversationListTopFirst(m, "conversationType", "type"); ok { + row["conversationType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// conversationListTopResolveList locates the conversation array inside the +// response, tolerating a bare top-level list or nesting one level under a +// common envelope. +func conversationListTopResolveList(data map[string]any) []any { + for _, key := range []string{"conversationList", "conversations", "topConversations", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// conversationListTopFirst returns the first present candidate key's value. +func conversationListTopFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ConversationClearMessages clears a conversation's chat records (clear_conversation_messages, im). +var ConversationClearMessages = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-clear-messages", + Product: "im", + Description: "清空当前用户指定会话的聊天记录(仅本人视角,不可逆)", + Intent: "当你要清空自己在某个会话里的聊天记录时使用;仅影响本人视角,但会实际删除且不可逆,需传 openConversationId,务必谨慎操作。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +conversation-clear-messages --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("clear_conversation_messages", map[string]any{"openConversationId": rt.Str("conversation-id")}) + }, +} + +// ConversationMarkRead marks a message read (mark_message_read, im). +var ConversationMarkRead = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-mark-read", + Product: "im", + Description: "标记消息已读(该消息及之前的消息都标记为已读)", + Intent: "当你想把某会话中某条消息及其之前的所有消息都标记为已读时使用;会实际更新已读位置,需传 openConversationId 和该消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +conversation-mark-read --conversation-id --message-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("mark_message_read", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMessageId": rt.Str("message-id"), + }) + }, +} + +// ConversationHide hides a conversation from the list (hide_conversation, im). +var ConversationHide = shortcut.Shortcut{ + Service: "chat", + Command: "+conversation-hide", + Product: "im", + Description: "会话列表中隐藏会话(收到新消息会重新出现)", + Intent: "当你想把某个会话从会话列表中暂时隐藏、让列表更清爽时使用;会实际隐藏该会话(收到新消息会自动重新出现),需传 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +conversation-hide --conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("hide_conversation", map[string]any{"openConversationId": rt.Str("conversation-id")}) + }, +} + +// ── category: 会话分组管理 (im) ────────────────────────────── + +// CategoryList lists user-defined conversation categories (list_user_define_conv_categories, im). +var CategoryList = shortcut.Shortcut{ + Service: "chat", + Command: "+category-list", + Product: "im", + Description: "获取用户自定义会话分组", + Intent: "当你想查看当前用户自建了哪些会话分组(如'工作群''项目群')时使用;只读返回分组列表及其 categoryId,供后续按分组拉会话或增删。", + Risk: shortcut.RiskRead, + Tips: []string{`dws chat +category-list`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("im", "list_user_define_conv_categories", map[string]any{}) + if err != nil { + return err + } + categories := categoryListProject(data) + return rt.Output(map[string]any{"count": len(categories), "categories": categories}) + }, +} + +// categoryListProject reshapes the raw list_user_define_conv_categories response +// into a clean {categoryId, title} list — clean output projection. +// Both the list container and the per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func categoryListProject(data map[string]any) []map[string]any { + raw := categoryListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := categoryListFirst(m, "categoryId", "category_id", "id"); ok { + row["categoryId"] = v + } + if v, ok := categoryListFirst(m, "title", "categoryName", "name"); ok { + row["title"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// categoryListResolveList locates the category array inside the response, +// tolerating a bare top-level list or nesting one level under a common envelope. +func categoryListResolveList(data map[string]any) []any { + for _, key := range []string{"categoryList", "categories", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"categoryList", "categories", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// categoryListFirst returns the first present candidate key's value. +func categoryListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// CategoryListConversations lists conversations in a category (list_conversations_by_category, im). +var CategoryListConversations = shortcut.Shortcut{ + Service: "chat", + Command: "+category-list-conversations", + Product: "im", + Description: "拉取指定自定义会话分组下的会话", + Intent: "当你想查看某个自定义会话分组里都归入了哪些会话时使用;只读,需传 categoryId,可用 --exclude-muted 排除已免打扰会话。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + }, + Tips: []string{`dws chat +category-list-conversations --category-id <分组ID>`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"categoryId": rt.Int("category-id")} + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("im", "list_conversations_by_category", params) + if err != nil { + return err + } + convs := categoryConversationsProject(data) + payload := map[string]any{"count": len(convs), "conversations": convs} + if v, ok := categoryConversationsFirst(data, "nextCursor", "cursor"); ok { + payload["nextCursor"] = v + } + if v, ok := categoryConversationsFirst(data, "hasMore", "has_more"); ok { + payload["hasMore"] = v + } + return rt.Output(payload) + }, +} + +// categoryConversationsProject reshapes the raw list_conversations_by_category +// response into a clean conversation list — clean output projection. +// Both the list container and the per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func categoryConversationsProject(data map[string]any) []map[string]any { + raw := categoryConversationsResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := categoryConversationsFirst(m, "openConversationId", "conversationId", "id"); ok { + row["openConversationId"] = v + } + if v, ok := categoryConversationsFirst(m, "conversationName", "name", "title"); ok { + row["conversationName"] = v + } + if v, ok := categoryConversationsFirst(m, "conversationType", "type"); ok { + row["conversationType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// categoryConversationsResolveList locates the conversation array inside the +// response, tolerating a bare top-level list or nesting one level under a +// common envelope. +func categoryConversationsResolveList(data map[string]any) []any { + for _, key := range []string{"conversationList", "conversations", "result", "data", "list", "items"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// categoryConversationsFirst returns the first present candidate key's value. +func categoryConversationsFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// CategoryCreate creates a conversation category (create_conv_category, im). +var CategoryCreate = shortcut.Shortcut{ + Service: "chat", + Command: "+category-create", + Product: "im", + Description: "创建用户自定义会话分组", + Intent: "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "title", Type: shortcut.FlagString, Desc: "分组名称", Required: true}, + }, + Tips: []string{`dws chat +category-create --title "工作群"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("create_conv_category", map[string]any{"title": rt.Str("title")}) + }, +} + +// CategoryDelete deletes a conversation category (delete_conv_category, im). +var CategoryDelete = shortcut.Shortcut{ + Service: "chat", + Command: "+category-delete", + Product: "im", + Description: "删除用户自定义会话分组", + Intent: "当你想删除某个自定义会话分组时使用;会实际删除分组(不影响其中会话本身),不可逆,需传 categoryId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true}, + }, + Tips: []string{`dws chat +category-delete --category-id <分组ID>`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_conv_category", map[string]any{"categoryId": rt.Int("category-id")}) + }, +} + +// CategoryRename renames a conversation category (rename_conv_category, im). +var CategoryRename = shortcut.Shortcut{ + Service: "chat", + Command: "+category-rename", + Product: "im", + Description: "更新用户自定义会话分组的名称", + Intent: "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "新的分组名称", Required: true}, + }, + Tips: []string{`dws chat +category-rename --category-id <分组ID> --title "新名称"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("rename_conv_category", map[string]any{ + "categoryId": rt.Int("category-id"), + "title": rt.Str("title"), + }) + }, +} + +// CategoryAddConversation adds a conversation to categories (add_conv_to_categories, im). +var CategoryAddConversation = shortcut.Shortcut{ + Service: "chat", + Command: "+category-add-conversation", + Product: "im", + Description: "将会话移动到指定的自定义分组中", + Intent: "当你想把某个会话归入一个或多个自定义分组时使用;会实际把会话加入指定分组,需传会话 openConversationId 和目标分组 ID 列表。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "category-ids", Type: shortcut.FlagStringSlice, Desc: "目标分组 ID 列表", Required: true}, + }, + Tips: []string{`dws chat +category-add-conversation --group --category-ids 123,456`}, + Execute: func(rt *shortcut.RuntimeContext) error { + ids, err := toInt64Slice(rt.StrSlice("category-ids")) + if err != nil { + return fmt.Errorf("--category-ids: %w", err) + } + return rt.CallMCP("add_conv_to_categories", map[string]any{ + "openConversationId": rt.Str("group"), + "categoryIds": ids, + }) + }, +} + +// CategoryRemoveConversation removes a conversation from categories (remove_conv_from_categories, im). +var CategoryRemoveConversation = shortcut.Shortcut{ + Service: "chat", + Command: "+category-remove-conversation", + Product: "im", + Description: "将会话从指定的自定义分组中移出", + Intent: "当你想把某个会话从指定自定义分组中移出时使用;会实际从分组移除该会话(不删除会话本身),需传会话 openConversationId 和分组 ID 列表。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "category-ids", Type: shortcut.FlagStringSlice, Desc: "目标分组 ID 列表", Required: true}, + }, + Tips: []string{`dws chat +category-remove-conversation --group --category-ids 123,456`}, + Execute: func(rt *shortcut.RuntimeContext) error { + ids, err := toInt64Slice(rt.StrSlice("category-ids")) + if err != nil { + return fmt.Errorf("--category-ids: %w", err) + } + return rt.CallMCP("remove_conv_from_categories", map[string]any{ + "openConversationId": rt.Str("group"), + "categoryIds": ids, + }) + }, +} + +func init() { + shortcut.Register( + ConversationInfo, + ConversationSetTop, + ConversationMute, + ConversationMuteAtAll, + ConversationMuteRedEnvelope, + ConversationMarkUnread, + ConversationClearRedPoint, + ConversationClearAllRedPoint, + ConversationList, + ConversationListTop, + ConversationClearMessages, + ConversationMarkRead, + ConversationHide, + CategoryList, + CategoryListConversations, + CategoryCreate, + CategoryDelete, + CategoryRename, + CategoryAddConversation, + CategoryRemoveConversation, + ) +} diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go new file mode 100644 index 00000000..714f7b63 --- /dev/null +++ b/internal/shortcut/chat/chat_group.go @@ -0,0 +1,913 @@ +// 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 chat + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ChatSearch searches groups by keyword (search_groups on the im server). +var ChatSearch = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-search", + Product: "im", + Description: "按关键词搜索群聊", + Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"}, + {Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"}, + }, + Tips: []string{`dws chat +chat-search --query "项目冲刺"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "keyword": rt.Str("query"), + "limit": rt.Int("limit"), + "cursor": rt.Str("cursor"), + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + return rt.CallMCP("search_groups", params) + }, +} + +// ChatMembersList lists members of a group (get_group_members, chat server). +// ChatMembersGet batch-queries member detail by ids (list_group_member_by_ids, im). +var ChatMembersGet = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-members-get", + Product: "im", + Description: "根据成员 openDingTalkId 批量查询群成员详情", + Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true}, + }, + Tips: []string{`dws chat +chat-members-get --id --users odid1,odid2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_group_member_by_ids", map[string]any{ + "openConversationId": rt.Str("id"), + "memberOpenDingTalkIds": rt.StrSlice("users"), + }) + }, +} + +// ChatMemberAdd adds members to a group (add_group_member, chat server). +// ChatMemberRemove removes members from a group (remove_group_member, chat server). +// ChatUpdateName renames a group (update_group_name, chat server). +// ChatTransferOwner transfers group ownership (transfer_group_owner, im). +var ChatTransferOwner = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-transfer-owner", + Product: "im", + Description: "转让群主", + Intent: "当你要把群主身份转让给他人时使用;会实际变更群主(自己不再是群主),需传群 openConversationId 和新群主的 userId 或 openDingTalkId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "new-owner", Type: shortcut.FlagString, Desc: "新群主 userId 或 openDingTalkId", Required: true}, + }, + Tips: []string{`dws chat +chat-transfer-owner --group --new-owner `}, + Execute: func(rt *shortcut.RuntimeContext) error { + newOwner := rt.Str("new-owner") + params := map[string]any{"openConversationId": rt.Str("group")} + if isOpenID(newOwner) { + params["newOwnerOpenDingTalkId"] = newOwner + } else { + params["newOwnerUid"] = newOwner + } + return rt.CallMCP("transfer_group_owner", params) + }, +} + +// ChatInviteURL gets the group invite url (get_group_invite_url, im). +var ChatInviteURL = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-invite-url", + Product: "im", + Description: "获取群邀请链接", + Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"}, + }, + Tips: []string{`dws chat +chat-invite-url --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"openConversationId": rt.Str("group")} + if rt.Changed("expires-seconds") { + params["expiresSeconds"] = rt.Int("expires-seconds") + } + return rt.CallMCP("get_group_invite_url", params) + }, +} + +// ChatQuit quits a group (quit_group, im). +var ChatQuit = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-quit", + Product: "im", + Description: "退出群聊", + Intent: "当你想让当前用户主动退出某个群时使用;会实际退群,需传群 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +chat-quit --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("quit_group", map[string]any{"openConversationId": rt.Str("group")}) + }, +} + +// ChatUpdateIcon updates the group icon (update_group_icon, im). +var ChatUpdateIcon = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-update-icon", + Product: "im", + Description: "更新群头像", + Intent: "当你想更换群头像时使用;会实际更新群头像,需传群 openConversationId 和已上传头像的 mediaId(以 @ 开头)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "群头像 mediaId(以 @ 开头)", Required: true}, + }, + Tips: []string{`dws chat +chat-update-icon --group --icon-media-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_group_icon", map[string]any{ + "openConversationId": rt.Str("group"), + "iconMediaId": rt.Str("icon-media-id"), + }) + }, +} + +// ChatUpdateSettings updates a group setting (update_group_settings, im). +var ChatUpdateSettings = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-update-settings", + Product: "im", + Description: "更新群设置(settingKey + status)", + Intent: "当你想调整群的某项开关设置(如是否可被搜索 searchable、是否仅管理员可@所有人 onlyAdminCanAtAll)时使用;会实际修改群设置,需传 settingKey 和 status(0关/1开)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "setting-key", Type: shortcut.FlagString, Desc: "群设置项 key,如 searchable / onlyAdminCanAtAll", Required: true}, + {Name: "status", Type: shortcut.FlagInt, Desc: "设置值:0=关闭,1=开启", Required: true}, + }, + Tips: []string{`dws chat +chat-update-settings --group --setting-key searchable --status 1`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_group_settings", map[string]any{ + "openConversationId": rt.Str("group"), + "settingKey": rt.Str("setting-key"), + "status": rt.Int("status"), + }) + }, +} + +// ChatDismiss dismisses (destroys) a group (dismiss_group, im). +var ChatDismiss = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-dismiss", + Product: "im", + Description: "解散群聊(不可逆,需群主权限)", + Intent: "当你要彻底解散一个群时使用;会实际销毁群聊,不可逆且需群主权限,仅需传群 openConversationId,操作前务必确认。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +chat-dismiss --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("dismiss_group", map[string]any{"openConversationId": rt.Str("group")}) + }, +} + +// ChatSetHistory sets new-member history visibility (update_show_history_msg_option, im). +var ChatSetHistory = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-set-history", + Product: "im", + Description: "设置新成员入群可查看历史消息范围", + Intent: "当你想控制新成员入群后能看到多少历史消息时使用;会实际修改群配置,需传群 openConversationId 和范围(FORBIDDEN 不可见 / RECENT_100 最近100条 / ALL 全部)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "option", Type: shortcut.FlagString, Desc: "可见范围", Required: true, Enum: []string{"FORBIDDEN", "RECENT_100", "ALL"}}, + }, + Tips: []string{`dws chat +chat-set-history --group --option RECENT_100`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_show_history_msg_option", map[string]any{ + "openConversationId": rt.Str("group"), + "option": rt.Str("option"), + }) + }, +} + +// ChatUpdateNick sets the caller's in-group nickname (update_group_nick, im). +var ChatUpdateNick = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-update-nick", + Product: "im", + Description: "设置当前用户在群内的群昵称", + Intent: "当你想设置当前用户在某个群里显示的群昵称时使用;会实际更新本人在该群的昵称,需传群 openConversationId 和昵称。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "nick", Type: shortcut.FlagString, Desc: "个人群昵称", Required: true}, + }, + Tips: []string{`dws chat +chat-update-nick --group --nick "我的群昵称"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_group_nick", map[string]any{ + "openConversationId": rt.Str("group"), + "nick": rt.Str("nick"), + }) + }, +} + +// ChatUpdateAlias sets the caller's private alias for a group (update_user_group_alias, im). +var ChatUpdateAlias = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-update-alias", + Product: "im", + Description: "设置群备注(仅自己可见)", + Intent: "当你想给某个群设置仅自己可见的备注名以便区分同名群时使用;会实际保存本人对该群的备注,需传群 openConversationId 和备注标题。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "alias-title", Type: shortcut.FlagString, Desc: "群备注标题", Required: true}, + }, + Tips: []string{`dws chat +chat-update-alias --group --alias-title "项目A群"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_user_group_alias", map[string]any{ + "openConversationId": rt.Str("group"), + "aliasTitle": rt.Str("alias-title"), + }) + }, +} + +// ChatListMine lists groups the caller owns/administers (list_owned_or_admin_groups, im). +var ChatListMine = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-list-mine", + Product: "im", + Description: "拉取我创建/管理的群", + Intent: "当你想查看自己作为群主或管理员在管理哪些群时使用;只读分页返回,可用 --role OWNER/ADMIN 按角色过滤。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "role", Type: shortcut.FlagString, Desc: "角色过滤", Enum: []string{"OWNER", "ADMIN"}}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "最多返回群数量,不传返回全部"}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"}, + }, + Tips: []string{`dws chat +chat-list-mine --role OWNER`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("role") { + params["roleFilter"] = rt.Str("role") + } + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("im", "list_owned_or_admin_groups", params) + if err != nil { + return err + } + groups := chatListMineProject(data) + return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + }, +} + +// chatListMineProject reshapes list_owned_or_admin_groups into a clean group +// list ({openConversationId, name, role, ownerUserId}) — output-projection +// clean output projection. List container and per-item field names are probed +// defensively across candidate keys so shape drift yields an empty list rather +// than a crash or fabricated data. +func chatListMineProject(data map[string]any) []map[string]any { + raw := chatGroupResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := chatGroupFirst(m, "openConversationId", "openconversation_id", "conversationId", "id"); ok { + row["openConversationId"] = v + } + if v, ok := chatGroupFirst(m, "name", "groupName", "title"); ok { + row["name"] = v + } + if v, ok := chatGroupFirst(m, "role", "roleType", "memberRole"); ok { + row["role"] = v + } + if v, ok := chatGroupFirst(m, "ownerUserId", "ownerId", "owner"); ok { + row["ownerUserId"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ChatListAll paginates all groups the caller joined (list_my_groups_pagination, im). +var ChatListAll = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-list-all", + Product: "im", + Description: "分页拉取我加入的所有群列表", + Intent: "当你想遍历当前用户加入的所有群做统计或批量操作时使用;只读分页返回全部已加入的群列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量(最大 200)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"}, + }, + Tips: []string{`dws chat +chat-list-all --limit 50`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if c := rt.Str("cursor"); c != "" && c != "0" { + params["cursor"] = c + } + data, err := rt.CallMCPData("im", "list_my_groups_pagination", params) + if err != nil { + return err + } + groups := chatListAllProject(data) + payload := map[string]any{"count": len(groups), "groups": groups} + if v, ok := chatGroupFirst(data, "nextCursor", "next_cursor", "cursor"); ok { + payload["nextCursor"] = v + } + if v, ok := chatGroupFirst(data, "hasMore", "has_more"); ok { + payload["hasMore"] = v + } + return rt.Output(payload) + }, +} + +// chatListAllProject reshapes list_my_groups_pagination into a clean group list +// ({openConversationId, name}) — clean output projection. List +// container and per-item field names are probed defensively across candidate +// keys so shape drift yields an empty list rather than a crash or fabricated +// data. +func chatListAllProject(data map[string]any) []map[string]any { + raw := chatGroupResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := chatGroupFirst(m, "openConversationId", "openconversation_id", "conversationId", "id"); ok { + row["openConversationId"] = v + } + if v, ok := chatGroupFirst(m, "name", "groupName", "title"); ok { + row["name"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// chatGroupResolveList locates the list payload inside a group-list response, +// tolerating a bare top-level array or nesting under common envelope keys. +func chatGroupResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// chatGroupFirst returns the first present candidate key's value. +func chatGroupFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ChatListJoinRequests paginates join-validation records (list_apply_join_group_records, im). +var ChatListJoinRequests = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-list-join-requests", + Product: "im", + Description: "分页拉取入群验证记录", + Intent: "当你作为群主/管理员想查看待处理的入群申请时使用;只读分页返回入群验证记录(含 recordId、申请人与邀请人 ID),供后续用 chat-audit-join 审批。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "单页数量(最大 50)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"}, + }, + Tips: []string{`dws chat +chat-list-join-requests --limit 30`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + return rt.CallMCP("list_apply_join_group_records", params) + }, +} + +// ChatAuditJoin audits a join-validation record (audit_join_group, im). +var ChatAuditJoin = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-audit-join", + Product: "im", + Description: "审批入群验证(通过/拒绝/删除/忽略/拉黑)", + Intent: "当你要处理某条入群申请时使用;会实际执行通过/拒绝/删除/忽略/拉黑动作,需传群 openConversationId、recordId、申请人与邀请人 openDingTalkId 及 status。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "record-id", Type: shortcut.FlagInt, Desc: "申请记录 ID", Required: true}, + {Name: "applicant", Type: shortcut.FlagString, Desc: "申请人 openDingTalkId", Required: true}, + {Name: "inviter", Type: shortcut.FlagString, Desc: "邀请人 openDingTalkId", Required: true}, + {Name: "status", Type: shortcut.FlagString, Desc: "审批动作", Required: true, Enum: []string{"AuditApprove", "AuditDelete", "AuditIgnore", "AuditRefuse", "AuditBlock"}}, + {Name: "description", Type: shortcut.FlagString, Desc: "审批说明"}, + }, + Tips: []string{`dws chat +chat-audit-join --group --record-id 123 --applicant --inviter --status AuditApprove`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "openConversationId": rt.Str("group"), + "applyRecordId": rt.Int("record-id"), + "applicantOpenDingTalkId": rt.Str("applicant"), + "inviterOpenDingTalkId": rt.Str("inviter"), + "status": rt.Str("status"), + } + if rt.Changed("description") { + params["auditDescription"] = rt.Str("description") + } + return rt.CallMCP("audit_join_group", params) + }, +} + +// ChatGetByID looks up a group by numeric group id (get_conv_info_by_group_id, im). +var ChatGetByID = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-get-by-id", + Product: "im", + Description: "根据群号获取群聊信息", + Intent: "当你只知道群号(数字)、需要换取群 openConversationId 及群信息时使用;只读,需传 --group-id。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group-id", Type: shortcut.FlagInt, Desc: "群号(数字类型)", Required: true}, + }, + Tips: []string{`dws chat +chat-get-by-id --group-id 12345678`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_conv_info_by_group_id", map[string]any{"groupId": rt.Int("group-id")}) + }, +} + +// ChatAddBot adds a custom robot to a group (add_robot_to_group, bot). +var ChatAddBot = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-add-bot", + Product: "bot", + Description: "将机器人添加到群中", + Intent: "当你想把某个机器人添加进群(比如让日报机器人进群播报)时使用;会实际把机器人加入群聊,需传机器人 robotCode 和群 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true}, + {Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +chat-add-bot --robot-code --id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("add_robot_to_group", map[string]any{ + "robotCode": rt.Str("robot-code"), + "openConversationId": rt.Str("id"), + }) + }, +} + +// ChatBots lists robots in a group (list_group_bots, bot). +var ChatBots = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-bots", + Product: "bot", + Description: "查看群内所有机器人", + Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +chat-bots --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")}) + if err != nil { + return err + } + bots := chatBotsProject(data) + return rt.Output(map[string]any{"count": len(bots), "bots": bots}) + }, +} + +// chatBotsProject reshapes list_group_bots into a clean bot list +// ({openBotId, name}) — clean output projection. List container and +// per-item field names are probed defensively across candidate keys so shape +// drift yields an empty list rather than a crash or fabricated data. +func chatBotsProject(data map[string]any) []map[string]any { + raw := chatGroupResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := chatGroupFirst(m, "openBotId", "open_bot_id", "botId", "robotCode", "id"); ok { + row["openBotId"] = v + } + if v, ok := chatGroupFirst(m, "name", "botName", "nick", "title"); ok { + row["name"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ChatRemoveBot removes a robot from a group (remove_robot_in_group, bot). +var ChatRemoveBot = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-remove-bot", + Product: "bot", + Description: "从群内移除机器人", + Intent: "当你想把某个机器人从群里移除时使用;会实际移除机器人,不可逆,需传群 openConversationId 和机器人 openBotId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "bot-id", Type: shortcut.FlagString, Desc: "机器人 openBotId", Required: true}, + }, + Tips: []string{`dws chat +chat-remove-bot --id --bot-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("remove_robot_in_group", map[string]any{ + "openConversationId": rt.Str("id"), + "openBotId": rt.Str("bot-id"), + }) + }, +} + +// ChatSetAdmin sets/unsets group admins (update_conv_member_roles, im). +var ChatSetAdmin = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-set-admin", + Product: "im", + Description: "设置 / 取消群管理员", + Intent: "当你想把某些成员设为或取消群管理员时使用;会实际变更成员角色,需传群 openConversationId 和成员 userId/openDingTalkId 列表,加 --off 取消管理员。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 userId 或 openDingTalkId 列表", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "取消管理员(不传则设为管理员)"}, + }, + Tips: []string{`dws chat +chat-set-admin --group --users userId1,userId2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, openIDs := splitIDs(rt.StrSlice("users")) + params := map[string]any{ + "openConversationId": rt.Str("group"), + "admin": !rt.Bool("off"), + } + if len(userIDs) > 0 { + params["uids"] = userIDs + } + if len(openIDs) > 0 { + params["openDingTalkIds"] = openIDs + } + return rt.CallMCP("update_conv_member_roles", params) + }, +} + +// ChatMute mutes/unmutes the whole group (set_group_mute, im). +var ChatMute = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-mute", + Product: "im", + Description: "全员禁言 / 取消全员禁言", + Intent: "当你想对整个群开启或取消全员禁言时使用;会实际切换群的全员禁言状态,需传群 openConversationId,加 --off 取消禁言。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "off", Type: shortcut.FlagBool, Desc: "取消全员禁言(不传则开启禁言)"}, + }, + Tips: []string{`dws chat +chat-mute --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_group_mute", map[string]any{ + "openConversationId": rt.Str("group"), + "mute": !rt.Bool("off"), + }) + }, +} + +// ChatMuteMember mutes/unmutes specific members (set_group_member_mute_list, im). +var ChatMuteMember = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-mute-member", + Product: "im", + Description: "指定群成员禁言 / 取消禁言", + Intent: "当你想只禁言或解禁群里的指定成员时使用;会实际把成员加入或移出禁言名单,需传群 openConversationId 和成员列表,禁言时还需 --mute-time(毫秒)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 userId 或 openDingTalkId 列表", Required: true}, + {Name: "mute-time", Type: shortcut.FlagInt, Desc: "禁言时长(毫秒),如 300000/3600000/86400000/604800000/2592000000"}, + {Name: "off", Type: shortcut.FlagBool, Desc: "移出禁言名单(不传则加入禁言名单)"}, + }, + Tips: []string{`dws chat +chat-mute-member --group --users userId1 --mute-time 3600000`}, + Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, openIDs := splitIDs(rt.StrSlice("users")) + off := rt.Bool("off") + params := map[string]any{ + "openConversationId": rt.Str("group"), + "mute": !off, + } + if len(userIDs) > 0 { + params["uids"] = userIDs + } + if len(openIDs) > 0 { + params["openDingTalkIds"] = openIDs + } + if !off { + if rt.Int("mute-time") <= 0 { + return fmt.Errorf("--mute-time 为禁言时必填(毫秒)") + } + params["muteTime"] = rt.Int("mute-time") + } + return rt.CallMCP("set_group_member_mute_list", params) + }, +} + +// ── group-role: 群身份管理 (im) ────────────────────────────── + +// ChatRoleList lists custom group roles (list_custom_group_roles, im). +var ChatRoleList = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-list", + Product: "im", + Description: "拉取会话的群身份列表", + Intent: "当你想查看某群自定义的群身份(如'班长''值日')都有哪些时使用;需传群 openConversationId,只读返回群身份列表及 openRoleId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +chat-role-list --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("im", "list_custom_group_roles", map[string]any{"openConversationId": rt.Str("group")}) + if err != nil { + return err + } + roles := chatRoleListProject(data) + return rt.Output(map[string]any{"count": len(roles), "roles": roles}) + }, +} + +// chatRoleListProject reshapes list_custom_group_roles into a clean group-role +// list ({openRoleId, name}) — clean output projection. List +// container and per-item field names are probed defensively across candidate +// keys so shape drift yields an empty list rather than a crash or fabricated +// data. +func chatRoleListProject(data map[string]any) []map[string]any { + raw := chatGroupResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := chatGroupFirst(m, "openRoleId", "open_role_id", "roleId", "id"); ok { + row["openRoleId"] = v + } + if v, ok := chatGroupFirst(m, "name", "roleName", "title"); ok { + row["name"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ChatRoleAdd adds a custom group role (add_custom_group_role, im). +var ChatRoleAdd = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-add", + Product: "im", + Description: "添加群身份", + Intent: "当你想在群里新增一个自定义群身份/头衔时使用;会实际创建群身份,需传群 openConversationId 和身份名称。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "群身份名称", Required: true}, + }, + Tips: []string{`dws chat +chat-role-add --group --name "管理员"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("add_custom_group_role", map[string]any{ + "openConversationId": rt.Str("group"), + "name": rt.Str("name"), + }) + }, +} + +// ChatRoleUpdate renames a custom group role (update_custom_group_role, im). +var ChatRoleUpdate = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-update", + Product: "im", + Description: "更新群身份名称", + Intent: "当你想重命名已有的群身份时使用;会实际更新身份名称,需传群 openConversationId、身份 openRoleId 和新名称。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "群身份新名称", Required: true}, + }, + Tips: []string{`dws chat +chat-role-update --group --role-id --name "新名称"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_custom_group_role", map[string]any{ + "openConversationId": rt.Str("group"), + "openRoleId": rt.Str("role-id"), + "name": rt.Str("name"), + }) + }, +} + +// ChatRoleRemove deletes a custom group role (remove_custom_group_role, im). +var ChatRoleRemove = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-remove", + Product: "im", + Description: "删除群身份", + Intent: "当你想删除某个自定义群身份时使用;会实际删除群身份,不可逆,需传群 openConversationId 和身份 openRoleId。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId", Required: true}, + }, + Tips: []string{`dws chat +chat-role-remove --group --role-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("remove_custom_group_role", map[string]any{ + "openConversationId": rt.Str("group"), + "openRoleId": rt.Str("role-id"), + }) + }, +} + +// ChatRoleSetUser overwrites a user's group roles (set_custom_user_roles, im). +var ChatRoleSetUser = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-set-user", + Product: "im", + Description: "设置用户的群身份(覆盖该用户的全部群身份)", + Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, + {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId 列表(空则清除全部)", Required: true}, + }, + Tips: []string{`dws chat +chat-role-set-user --group --user --role-ids roleId1,roleId2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + user := rt.Str("user") + params := map[string]any{ + "openConversationId": rt.Str("group"), + "openRoleIds": rt.StrSlice("role-ids"), + } + if isOpenID(user) { + params["openDingTalkId"] = user + } else { + params["userId"] = user + } + return rt.CallMCP("set_custom_user_roles", params) + }, +} + +// ChatRoleRemoveUser removes specific roles from a user (remove_custom_user_roles, im). +var ChatRoleRemoveUser = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-remove-user", + Product: "im", + Description: "移除用户的指定群身份", + Intent: "当你只想撤销某成员的部分群身份、保留其余时使用;会实际移除指定的群身份,需传群、用户和要移除的 openRoleId 列表。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, + {Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "要移除的群身份 openRoleId 列表", Required: true}, + }, + Tips: []string{`dws chat +chat-role-remove-user --group --user --role-ids roleId1`}, + Execute: func(rt *shortcut.RuntimeContext) error { + user := rt.Str("user") + params := map[string]any{ + "openConversationId": rt.Str("group"), + "openRoleIds": rt.StrSlice("role-ids"), + } + if isOpenID(user) { + params["openDingTalkId"] = user + } else { + params["userId"] = user + } + return rt.CallMCP("remove_custom_user_roles", params) + }, +} + +// ChatRoleQueryUser queries a member's group roles (query_custom_user_roles, im). +var ChatRoleQueryUser = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-role-query-user", + Product: "im", + Description: "查询群成员的群身份", + Intent: "当你想查看某个群成员当前拥有哪些群身份时使用;只读,需传群 openConversationId 和用户 userId 或 openDingTalkId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true}, + }, + Tips: []string{`dws chat +chat-role-query-user --group --user `}, + Execute: func(rt *shortcut.RuntimeContext) error { + user := rt.Str("user") + params := map[string]any{"openConversationId": rt.Str("group")} + if isOpenID(user) { + params["openDingTalkId"] = user + } else { + params["userId"] = user + } + return rt.CallMCP("query_custom_user_roles", params) + }, +} + +func init() { + shortcut.Register( + ChatSearch, + ChatMembersGet, + ChatTransferOwner, + ChatInviteURL, + ChatQuit, + ChatUpdateIcon, + ChatUpdateSettings, + ChatDismiss, + ChatSetHistory, + ChatUpdateNick, + ChatUpdateAlias, + ChatListMine, + ChatListAll, + ChatListJoinRequests, + ChatAuditJoin, + ChatGetByID, + ChatAddBot, + ChatBots, + ChatRemoveBot, + ChatSetAdmin, + ChatMute, + ChatMuteMember, + ChatRoleList, + ChatRoleAdd, + ChatRoleUpdate, + ChatRoleRemove, + ChatRoleSetUser, + ChatRoleRemoveUser, + ChatRoleQueryUser, + ) +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go new file mode 100644 index 00000000..e575b4c9 --- /dev/null +++ b/internal/shortcut/chat/chat_message.go @@ -0,0 +1,1007 @@ +// 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 chat + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MessagesSend sends a text/markdown message as the current user +// (send_personal_message, chat server). Media/file variants are not covered. +// MessagesReply quote-replies a message (send_personal_message, chat server). +// MessagesSendByBot sends a group message via a robot (send_robot_group_message, bot). +var MessagesSendByBot = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-send-by-bot", + Product: "bot", + Description: "机器人向群聊发送 Markdown 消息", + Intent: "当你要用机器人向某群推送 Markdown 消息(如日报、告警播报)时使用;会实际以机器人身份发群消息,需传 robotCode、群 openConversationId、标题与正文,可 @人或 @所有人。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true}, + {Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"}, + {Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId 列表"}, + {Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"}, + }, + Tips: []string{`dws chat +messages-send-by-bot --robot-code --group --title "日报" --text "## 今日完成"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "robotCode": rt.Str("robot-code"), + "openConversationId": rt.Str("group"), + "title": rt.Str("title"), + "markdown": rt.Str("text"), + } + if v := rt.StrSlice("at-user-ids"); len(v) > 0 { + params["atUserIds"] = v + } + if v := rt.StrSlice("at-open-dingtalk-ids"); len(v) > 0 { + params["atOpendingtalkIds"] = v + } + if rt.Bool("at-all") { + params["isAtAll"] = "true" + } + return rt.CallMCP("send_robot_group_message", params) + }, +} + +// MessagesBatchSendByBot sends single-chat messages via a robot to users +// (batch_send_robot_msg_to_users, bot). +var MessagesBatchSendByBot = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-batch-send-by-bot", + Product: "bot", + Description: "机器人批量向用户发送单聊 Markdown 消息", + Intent: "当你要用机器人给多个人分别发单聊 Markdown 消息(如批量提醒交周报)时使用;会实际批量发送单聊消息,需传 robotCode、接收人列表(userId 或 openDingTalkId)及标题正文。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 userId 列表"}, + {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表"}, + {Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"}, + }, + Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "robotCode": rt.Str("robot-code"), + "title": rt.Str("title"), + "markdown": rt.Str("text"), + } + if v := rt.StrSlice("users"); len(v) > 0 { + params["userIds"] = v + } + if v := rt.StrSlice("open-dingtalk-ids"); len(v) > 0 { + params["openDingtalkIds"] = v + } + if rt.Bool("at-all") { + params["isAtAll"] = "true" + } + return rt.CallMCP("batch_send_robot_msg_to_users", params) + }, +} + +// MessagesSendByWebhook sends via a custom robot webhook (send_message_by_custom_robot, bot). +var MessagesSendByWebhook = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-send-by-webhook", + Product: "bot", + Description: "自定义机器人 Webhook 发送群消息", + Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "token", Type: shortcut.FlagString, Desc: "Webhook token", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "消息正文", Required: true}, + {Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"}, + {Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号列表"}, + {Name: "at-users", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"}, + }, + Tips: []string{`dws chat +messages-send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "robotToken": rt.Str("token"), + "title": rt.Str("title"), + "text": rt.Str("text"), + } + if rt.Bool("at-all") { + params["isAtAll"] = true + } + if v := rt.StrSlice("at-mobiles"); len(v) > 0 { + params["atMobiles"] = v + } + if v := rt.StrSlice("at-users"); len(v) > 0 { + params["atUserIds"] = v + } + return rt.CallMCP("send_message_by_custom_robot", params) + }, +} + +// MessagesRecall recalls a message sent by the current user (recall_message, im). +var MessagesRecall = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-recall", + Product: "im", + Description: "撤回当前用户发送的消息", + Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +messages-recall --conversation-id --msg-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_message", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMessageId": rt.Str("msg-id"), + }) + }, +} + +// MessagesRecallByBot recalls a robot group message (recall_robot_group_message, bot). +var MessagesRecallByBot = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-recall-by-bot", + Product: "bot", + Description: "机器人撤回群消息", + Intent: "当你要撤回机器人此前发到群里的消息时使用;会实际撤回机器人群消息,需传 robotCode、群 openConversationId 和发送时返回的 processQueryKey 列表。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "keys", Type: shortcut.FlagStringSlice, Desc: "发送时返回的 processQueryKey 列表", Required: true}, + }, + Tips: []string{`dws chat +messages-recall-by-bot --robot-code --group --keys key1,key2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_robot_group_message", map[string]any{ + "robotCode": rt.Str("robot-code"), + "openConversationId": rt.Str("group"), + "processQueryKeys": rt.StrSlice("keys"), + }) + }, +} + +// MessagesBatchRecallByBot recalls robot single-chat messages (batch_recall_robot_users_msg, bot). +var MessagesBatchRecallByBot = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-batch-recall-by-bot", + Product: "bot", + Description: "机器人撤回单聊消息", + Intent: "当你要批量撤回机器人此前发出的单聊消息时使用;会实际撤回机器人单聊消息,需传 robotCode 和发送时返回的 processQueryKey 列表。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true}, + {Name: "keys", Type: shortcut.FlagStringSlice, Desc: "发送时返回的 processQueryKey 列表", Required: true}, + }, + Tips: []string{`dws chat +messages-batch-recall-by-bot --robot-code --keys key1,key2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("batch_recall_robot_users_msg", map[string]any{ + "robotCode": rt.Str("robot-code"), + "processQueryKeys": rt.StrSlice("keys"), + }) + }, +} + +// MessagesList pulls messages of a group (list_conversation_message_v2, chat server). +var MessagesList = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-list", + Description: "拉取群聊会话消息", + Intent: "当你想按时间拉取某个群的历史聊天记录(做回顾或分析)时使用;只读,需传群 openConversationId 和起始时间,--forward 控制往新还是往旧方向翻。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true}, + {Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"}, + }, + Tips: []string{`dws chat +messages-list --group --time "2025-03-01 00:00:00"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "openconversation_id": rt.Str("group"), + "time": rt.Str("time"), + "forward": rt.Bool("forward"), + } + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + data, err := rt.CallMCPData("chat", "list_conversation_message_v2", params) + if err != nil { + return err + } + messages := listMessagesProject(data) + return rt.Output(map[string]any{"count": len(messages), "messages": messages}) + }, +} + +// listMessagesProject reshapes a raw conversation-message list response into a +// clean {messageId, senderId, msgType, createTime, text} list — output-projection +// clean output projection. Both the list container and the per-item field names are +// probed defensively across candidate keys, so an empty or unexpected shape +// yields an empty list rather than a crash or fabricated data. +func listMessagesProject(data map[string]any) []map[string]any { + raw := listMessagesResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { + row["messageId"] = v + } + if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { + row["senderId"] = v + } + if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { + row["msgType"] = v + } + if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { + row["createTime"] = v + } + if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok { + row["text"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listMessagesResolveList locates the list payload, tolerating a bare top-level +// array container or nesting one level deeper under a common envelope key. +func listMessagesResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "messages", "messageList", "records"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "messages", "messageList", "records", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listMessagesFirst returns the first present candidate key's value. +func listMessagesFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// MessagesListDirect pulls messages of a single chat (list_individual_chat_message, chat server). +var MessagesListDirect = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-list-direct", + Description: "拉取单聊会话消息", + Intent: "当你想按时间拉取与某人单聊的历史消息时使用;只读,需传对方 userId 或 openDingTalkId 及起始时间,--forward 控制翻页方向。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "user", Type: shortcut.FlagString, Desc: "对方 userId(与 --open-dingtalk-id 二选一)"}, + {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "对方 openDingTalkId(与 --user 二选一)"}, + {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true}, + {Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"}, + }, + Tips: []string{`dws chat +messages-list-direct --user --time "2025-03-01 00:00:00"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "time": rt.Str("time"), + "forward": rt.Bool("forward"), + } + switch { + case rt.Str("open-dingtalk-id") != "": + params["openDingTalkId"] = rt.Str("open-dingtalk-id") + case rt.Str("user") != "": + params["userId"] = rt.Str("user") + default: + return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一") + } + if rt.Int("limit") > 0 { + params["limit"] = rt.Int("limit") + } + data, err := rt.CallMCPData("chat", "list_individual_chat_message", params) + if err != nil { + return err + } + messages := listMessagesProject(data) + return rt.Output(map[string]any{"count": len(messages), "messages": messages}) + }, +} + +// MessagesListTopicReplies pulls topic replies (list_topic_replies, chat server). +// MessagesListAll pulls all messages in a time range (search_messages_by_time_range, chat server). +// MessagesListFocused pulls messages from specially-followed people (list_special_focus_messages, chat server). +// MessagesListUnreadConversations lists conversations with unread messages +// (unread_message_conversation_list, chat server). +var MessagesListUnreadConversations = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-list-unread-conversations", + Description: "获取有未读消息的会话列表", + Intent: "当你想快速定位哪些会话还有未读消息时使用;只读返回有未读的会话列表,可用 --exclude-muted 排除已免打扰会话。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "count", Type: shortcut.FlagInt, Desc: "返回的会话条数"}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + }, + Tips: []string{`dws chat +messages-list-unread-conversations --count 20`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Int("count") > 0 { + params["count"] = rt.Int("count") + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("chat", "unread_message_conversation_list", params) + if err != nil { + return err + } + conversations := listUnreadConversationsProject(data) + return rt.Output(map[string]any{"count": len(conversations), "conversations": conversations}) + }, +} + +// listUnreadConversationsProject reshapes the raw unread_message_conversation_list +// response into a clean {conversationId, title, unreadCount, lastMessageTime} list +// — clean output projection. Both the list container and per-item +// field names are probed defensively across candidate keys, so an empty or +// unexpected shape yields an empty list rather than a crash or fabricated data. +func listUnreadConversationsProject(data map[string]any) []map[string]any { + raw := listUnreadConversationsResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listUnreadConversationsFirst(m, "openConversationId", "conversationId", "openConvId", "cid"); ok { + row["conversationId"] = v + } + if v, ok := listUnreadConversationsFirst(m, "title", "conversationTitle", "name"); ok { + row["title"] = v + } + if v, ok := listUnreadConversationsFirst(m, "unreadCount", "unread", "unReadCount", "count"); ok { + row["unreadCount"] = v + } + if v, ok := listUnreadConversationsFirst(m, "lastMessageTime", "lastMsgTime", "latestMessageTime", "updateTime"); ok { + row["lastMessageTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listUnreadConversationsResolveList locates the list payload, tolerating a bare +// top-level array container or nesting one level deeper under a common envelope. +func listUnreadConversationsResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "conversations", "conversationList"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "conversations", "conversationList", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listUnreadConversationsFirst returns the first present candidate key's value. +func listUnreadConversationsFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// MessagesMget batch-queries messages by id (list_messages_by_ids, im). +var MessagesMget = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-mget", + Product: "im", + Description: "根据消息 ID 批量查询消息(最多 50 条)", + Intent: "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表", Required: true}, + }, + Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_messages_by_ids", map[string]any{"openMsgIds": rt.StrSlice("msg-ids")}) + }, +} + +// MessagesQuerySendStatus queries send status of a message (query_message_send_status, im). +var MessagesQuerySendStatus = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-query-send-status", + Product: "im", + Description: "查询消息发送状态", + Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true}, + }, + Tips: []string{`dws chat +messages-query-send-status --open-task-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")}) + }, +} + +// MessagesReadStatus queries read/unread status of a message (query_msg_read_status, im). +var MessagesReadStatus = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-read-status", + Product: "im", + Description: "查询消息的已读/未读状态", + Intent: "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(当前用户发送的消息)", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标 userId 或 openDingTalkId 列表(不传返回全部接收者)"}, + }, + Tips: []string{`dws chat +messages-read-status --conversation-id --message-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMessageId": rt.Str("message-id"), + } + if v := rt.StrSlice("users"); len(v) > 0 { + userIDs, openIDs := splitIDs(v) + if len(userIDs) > 0 { + params["targetUserIds"] = userIDs + } + if len(openIDs) > 0 { + params["targetOpenDingTalkIds"] = openIDs + } + } + return rt.CallMCP("query_msg_read_status", params) + }, +} + +// MessagesAddEmoji adds an emoji reaction (add_emoji_reaction, im). +var MessagesAddEmoji = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-add-emoji", + Product: "im", + Description: "对消息添加 emoji 表情回应", + Intent: "当你想给某条消息点一个 emoji 表情回应时使用;会实际添加表情回应,需传会话 openConversationId、消息 openMsgId 和表情名称。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true}, + {Name: "emoji", Type: shortcut.FlagString, Desc: "emoji 表情名称", Required: true}, + }, + Tips: []string{`dws chat +messages-add-emoji --conversation-id --msg-id --emoji "赞"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("add_emoji_reaction", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMsgId": rt.Str("msg-id"), + "emojiName": rt.Str("emoji"), + }) + }, +} + +// MessagesRemoveEmoji removes an emoji reaction (remove_emoji_reaction, im). +var MessagesRemoveEmoji = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-remove-emoji", + Product: "im", + Description: "移除消息的 emoji 表情回应", + Intent: "当你想取消此前给某条消息添加的 emoji 表情回应时使用;会实际移除表情回应,需传会话 openConversationId、消息 openMsgId 和表情名称。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true}, + {Name: "emoji", Type: shortcut.FlagString, Desc: "emoji 表情名称", Required: true}, + }, + Tips: []string{`dws chat +messages-remove-emoji --conversation-id --msg-id --emoji "赞"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("remove_emoji_reaction", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMsgId": rt.Str("msg-id"), + "emojiName": rt.Str("emoji"), + }) + }, +} + +// MessagesAddTextEmotion adds a text emotion reaction (add_text_emotion, im). +var MessagesAddTextEmotion = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-add-text-emotion", + Product: "im", + Description: "对消息添加文字表情回应", + Intent: "当你想给某条消息添加自定义文字表情回应时使用;会实际添加文字表情,需传会话、消息 openMsgId 及由 create-text-emotion 得到的 emotionId 等参数。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true}, + {Name: "emotion-id", Type: shortcut.FlagString, Desc: "表情 ID(由 create-text-emotion 获取)", Required: true}, + {Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true}, + {Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID", Required: true}, + }, + Tips: []string{`dws chat +messages-add-text-emotion --conversation-id --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("add_text_emotion", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMsgId": rt.Str("msg-id"), + "emotionId": rt.Str("emotion-id"), + "emotionName": rt.Str("emotion-name"), + "text": rt.Str("text"), + "backgroundId": rt.Str("background-id"), + }) + }, +} + +// MessagesRemoveTextEmotion removes a text emotion reaction (remove_text_emotion, im). +var MessagesRemoveTextEmotion = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-remove-text-emotion", + Product: "im", + Description: "移除消息的文字表情回应", + Intent: "当你想移除此前给某条消息添加的文字表情回应时使用;会实际移除文字表情,需传会话、消息 openMsgId 及对应的表情参数。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true}, + {Name: "emotion-id", Type: shortcut.FlagString, Desc: "表情 ID", Required: true}, + {Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true}, + {Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID", Required: true}, + }, + Tips: []string{`dws chat +messages-remove-text-emotion --conversation-id --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("remove_text_emotion", map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "openMsgId": rt.Str("msg-id"), + "emotionId": rt.Str("emotion-id"), + "emotionName": rt.Str("emotion-name"), + "text": rt.Str("text"), + "backgroundId": rt.Str("background-id"), + }) + }, +} + +// MessagesCreateTextEmotion creates a text emotion template (create_text_emotion, im). +var MessagesCreateTextEmotion = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-create-text-emotion", + Product: "im", + Description: "创建文字表情(获取 emotionId)", + Intent: "当你要先创建一个文字表情模板(拿到 emotionId 供 add-text-emotion 使用)时使用;会实际创建文字表情,需传表情名称和文字内容。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true}, + {Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID(可选)"}, + }, + Tips: []string{`dws chat +messages-create-text-emotion --emotion-name "赞" --text "nice"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "emotionName": rt.Str("emotion-name"), + "text": rt.Str("text"), + } + if rt.Changed("background-id") { + params["backgroundId"] = rt.Str("background-id") + } + return rt.CallMCP("create_text_emotion", params) + }, +} + +// MessagesSendCard creates and pushes a streaming card (create_and_send_card, im). +var MessagesSendCard = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-send-card", + Product: "im", + Description: "创建并推送流式卡片(需配合 messages-update-card)", + Intent: "当你要发送一张可后续流式更新的卡片消息(如 AI 逐字输出)时使用;会实际推送卡片并返回 bizId,群 openConversationId 或单聊接收者 openDingTalkId 二选一,配合 messages-update-card 更新。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --receiver 互斥)"}, + {Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group 互斥)"}, + }, + Tips: []string{`dws chat +messages-send-card --group `}, + Execute: func(rt *shortcut.RuntimeContext) error { + group := rt.Str("group") + receiver := rt.Str("receiver") + if group == "" && receiver == "" { + return fmt.Errorf("--group 或 --receiver 必填其一") + } + if group != "" && receiver != "" { + return fmt.Errorf("--group 与 --receiver 互斥") + } + params := map[string]any{} + if group != "" { + params["openConversationId"] = group + } else { + params["receiverOpenDingTalkId"] = receiver + } + return rt.CallMCP("create_and_send_card", params) + }, +} + +// MessagesUpdateCard streams updated card content (update_streaming_card, im). +var MessagesUpdateCard = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-update-card", + Product: "im", + Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)", + Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true}, + {Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true}, + }, + Tips: []string{`dws chat +messages-update-card --biz-id --content "内容" --flow-status 3`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("update_streaming_card", map[string]any{ + "bizId": rt.Str("biz-id"), + "msgContent": rt.Str("content"), + "flowStatus": rt.Int("flow-status"), + }) + }, +} + +// MessagesResourceURL gets a message resource download url (get_resource_download_url, im). +var MessagesResourceURL = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-resource-url", + Product: "im", + Description: "获取消息资源(图片/视频/语音)下载链接", + Intent: "当你想下载消息里的图片/视频/语音等资源时使用;只读换取临时下载链接,需传资源 mediaId、消息 openMessageId 和会话 openConversationId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}}, + {Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +messages-resource-url --type mediaId --resource-id --message-id --open-conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_resource_download_url", map[string]any{ + "resourceType": rt.Str("type"), + "resourceId": rt.Str("resource-id"), + "openMessageId": rt.Str("message-id"), + "openConversationId": rt.Str("open-conversation-id"), + }) + }, +} + +// MessagesForward forwards one message (forward_message, im). +var MessagesForward = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-forward", + Product: "im", + Description: "转发单条消息", + Intent: "当你想把一条消息转发到另一个会话时使用;会实际转发消息,需传源会话 openConversationId、源消息 openMessageId 和目标会话 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "源消息 openMessageId", Required: true}, + {Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"}, + }, + Tips: []string{`dws chat +messages-forward --src-conversation-id --msg-id --dest-conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "srcOpenCid": rt.Str("src-conversation-id"), + "srcOpenMessageId": rt.Str("msg-id"), + "destOpenCid": rt.Str("dest-conversation-id"), + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("forward_message", params) + }, +} + +// MessagesCombineForward merge-forwards multiple messages (combine_forward_messages, im). +var MessagesCombineForward = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-combine-forward", + Product: "im", + Description: "合并转发多条消息", + Intent: "当你想把多条消息合并成一条转发到目标会话时使用;会实际合并转发,需传源会话、源消息 openMessageId 列表和目标会话 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true}, + {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "源消息 openMessageId 列表", Required: true}, + {Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"}, + }, + Tips: []string{`dws chat +messages-combine-forward --src-conversation-id --msg-ids id1,id2 --dest-conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "srcOpenCid": rt.Str("src-conversation-id"), + "srcOpenMessageIds": rt.StrSlice("msg-ids"), + "destOpenCid": rt.Str("dest-conversation-id"), + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("combine_forward_messages", params) + }, +} + +// MessagesForwardTopic forwards a topic message (forward_topic, im). +var MessagesForwardTopic = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-forward-topic", + Product: "im", + Description: "转发话题消息到目标会话", + Intent: "当你要把某条群话题消息转发到目标会话时使用;会实际转发话题消息,需传源消息、源会话、话题 threadId 和目标会话 openConversationId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "src-msg-id", Type: shortcut.FlagString, Desc: "源消息 openMessageId", Required: true}, + {Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true}, + {Name: "src-thread-id", Type: shortcut.FlagString, Desc: "话题 ID(convThread + 加密 convThreadId)", Required: true}, + {Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true}, + }, + Tips: []string{`dws chat +messages-forward-topic --src-msg-id --src-conversation-id --src-thread-id --dest-conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("forward_topic", map[string]any{ + "srcOpenMessageId": rt.Str("src-msg-id"), + "srcOpenConversationId": rt.Str("src-conversation-id"), + "srcOpenConvThreadId": rt.Str("src-thread-id"), + "destOpenConversationId": rt.Str("dest-conversation-id"), + }) + }, +} + +// MessagesSetPin pins a message (set_pin_message, im). +var MessagesSetPin = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-set-pin", + Product: "im", + Description: "钉住消息(Pin)", + Intent: "当你想把某条消息钉在会话中(Pin)以便成员随时查看时使用;会实际钉住消息,需传会话 openConversationId 和消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +messages-set-pin --open-conversation-id --msg-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_pin_message", map[string]any{ + "openConversationId": rt.Str("open-conversation-id"), + "openMessageId": rt.Str("msg-id"), + }) + }, +} + +// MessagesUnsetPin unpins a message (unset_pin_message, im). +var MessagesUnsetPin = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-unset-pin", + Product: "im", + Description: "取消钉住消息(Unpin)", + Intent: "当你想取消此前钉住的消息时使用;会实际取消 Pin,需传会话 openConversationId 和消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +messages-unset-pin --open-conversation-id --msg-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("unset_pin_message", map[string]any{ + "openConversationId": rt.Str("open-conversation-id"), + "openMessageId": rt.Str("msg-id"), + }) + }, +} + +// MessagesListPin lists pinned messages (list_pin_messages, im). +var MessagesListPin = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-list-pin", + Product: "im", + Description: "拉取会话中钉住的消息列表", + Intent: "当你想查看某会话里当前钉住的消息有哪些时使用;只读分页返回,需传会话 openConversationId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"}, + {Name: "size", Type: shortcut.FlagInt, Desc: "一次拉取的消息数量(默认 20,最大 100)"}, + }, + Tips: []string{`dws chat +messages-list-pin --open-conversation-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"openConversationId": rt.Str("open-conversation-id")} + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + if rt.Int("size") > 0 { + params["count"] = rt.Int("size") + } + data, err := rt.CallMCPData("im", "list_pin_messages", params) + if err != nil { + return err + } + pins := listPinProject(data) + return rt.Output(map[string]any{"count": len(pins), "pins": pins}) + }, +} + +// listPinProject reshapes the raw list_pin_messages response into a clean +// {messageId, senderId, pinTime, conversationId} list — output-projection +// clean output projection. Both the list container and per-item field names are probed +// defensively across candidate keys, so an empty or unexpected shape yields an +// empty list rather than a crash or fabricated data. +func listPinProject(data map[string]any) []map[string]any { + raw := listPinResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listPinFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { + row["messageId"] = v + } + if v, ok := listPinFirst(m, "senderOpenDingTalkId", "senderUserId", "operatorId", "senderId"); ok { + row["senderId"] = v + } + if v, ok := listPinFirst(m, "pinTime", "createTime", "gmtCreate", "operateTime"); ok { + row["pinTime"] = v + } + if v, ok := listPinFirst(m, "openConversationId", "conversationId", "openConvId"); ok { + row["conversationId"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listPinResolveList locates the list payload, tolerating a bare top-level array +// container or nesting one level deeper under a common envelope key. +func listPinResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "pinMessages", "pinnedMessages", "messages"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "pinMessages", "pinnedMessages", "messages", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listPinFirst returns the first present candidate key's value. +func listPinFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// MessagesSetTop pins a message to the top (set_top_message, im). +var MessagesSetTop = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-set-top", + Product: "im", + Description: "置顶消息", + Intent: "当你想把某条消息置顶到会话顶部时使用;会实际置顶消息,需传会话 openConversationId 和消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +messages-set-top --open-conversation-id --msg-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("set_top_message", map[string]any{ + "openConversationId": rt.Str("open-conversation-id"), + "openMessageId": rt.Str("msg-id"), + }) + }, +} + +// MessagesUnsetTop cancels a message top (unset_top_message, im). +var MessagesUnsetTop = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-unset-top", + Product: "im", + Description: "取消置顶消息", + Intent: "当你想取消此前置顶的消息时使用;会实际取消置顶,需传会话 openConversationId 和消息 openMessageId。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + }, + Tips: []string{`dws chat +messages-unset-top --open-conversation-id --msg-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("unset_top_message", map[string]any{ + "openConversationId": rt.Str("open-conversation-id"), + "openMessageId": rt.Str("msg-id"), + }) + }, +} + +func init() { + shortcut.Register( + MessagesSendByBot, + MessagesBatchSendByBot, + MessagesSendByWebhook, + MessagesRecall, + MessagesRecallByBot, + MessagesBatchRecallByBot, + MessagesList, + MessagesListDirect, + MessagesListUnreadConversations, + MessagesMget, + MessagesQuerySendStatus, + MessagesReadStatus, + MessagesAddEmoji, + MessagesRemoveEmoji, + MessagesAddTextEmotion, + MessagesRemoveTextEmotion, + MessagesCreateTextEmotion, + MessagesSendCard, + MessagesUpdateCard, + MessagesResourceURL, + MessagesForward, + MessagesCombineForward, + MessagesForwardTopic, + MessagesSetPin, + MessagesUnsetPin, + MessagesListPin, + MessagesSetTop, + MessagesUnsetTop, + ) +} diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go new file mode 100644 index 00000000..4f740ae3 --- /dev/null +++ b/internal/shortcut/contact/contact.go @@ -0,0 +1,502 @@ +// 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 contact provides declarative shortcuts for the DingTalk contact +// (通讯录) service: user / department / role / relation queries and the HR +// roster (花名册) lookups. Each shortcut maps 1:1 onto an MCP tool declared in +// internal/helpers/contact.go. +package contact + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + +// GetSelf 获取当前登录用户信息(我是谁 / 本人)。 +// ListFollowings 获取当前用户的特别关注列表。 +var ListFollowings = shortcut.Shortcut{ + Service: "contact", + Command: "+list-followings", + Product: "contact", + Description: "获取当前用户的特别关注列表", + Intent: "当你想查看本人在通讯录里「特别关注」的联系人名单(例如常打交道的同事、上级)时使用;无需输入,返回关注对象的用户列表,可用于快速定位这些人的 userId 再发消息或排日程。", + Risk: shortcut.RiskRead, + Tips: []string{ + `dws contact +list-followings`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + // Project the raw {arguments,result:{models:[…]}} envelope down to a + // clean {count, followings:[{openDingTalkId}]} list. + data, err := rt.CallMCPData("contact", "list_my_followings", nil) + if err != nil { + return err + } + followings := listFollowingsProject(data) + return rt.Output(map[string]any{"count": len(followings), "followings": followings}) + }, +} + +// listFollowingsProject reshapes a list_my_followings response into a clean +// list, unwrapping the result.models container the gateway uses. +func listFollowingsProject(data map[string]any) []map[string]any { + var raw []any + if res, ok := data["result"].(map[string]any); ok { + raw, _ = res["models"].([]any) + } + if raw == nil { + for _, k := range []string{"models", "list", "items"} { + if arr, ok := data[k].([]any); ok { + raw = arr + break + } + } + } + out := make([]map[string]any, 0, len(raw)) + for _, it := range raw { + m, ok := it.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + for _, k := range []string{"openDingTalkId", "openDingtalkId", "userId", "name"} { + if v, ok := m[k]; ok && v != nil { + row[k] = v + } + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// SearchUser 按关键词搜索通讯录用户。 +var SearchUser = shortcut.Shortcut{ + Service: "contact", + Command: "+search-user", + Product: "contact", + Description: "按关键词搜索通讯录用户", + Intent: "当你只知道某人的姓名、花名或部分名字,需要把它解析成 userId 及部门等信息以便后续发消息、排日程或指派任务时使用;输入搜索关键词(--query),返回匹配的用户列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + }, + Tips: []string{ + `dws contact +search-user --query "张三"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ + "keyword": rt.Str("query"), + }) + if err != nil { + return err + } + users := searchUserProject(data) + return rt.Output(map[string]any{"count": len(users), "users": users}) + }, +} + +// searchUserProject reshapes the raw search_contact_by_key_word response into a +// clean, stable user list (name/userId/flowerName/openDingTalkId/title) — the +// the clean output projection applied to every list command. +// Field names are probed defensively across candidate keys. +func searchUserProject(data map[string]any) []map[string]any { + raw, ok := data["result"].([]any) + if !ok { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + for _, k := range []string{"name", "userId", "flowerName", "openDingTalkId", "title"} { + if v, ok := m[k]; ok { + row[k] = v + } + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// SearchMobile 按手机号搜索通讯录用户。 +var SearchMobile = shortcut.Shortcut{ + Service: "contact", + Command: "+search-mobile", + Product: "contact", + Description: "按手机号搜索通讯录用户", + Intent: "当你手里只有某人的手机号、需要反查出对应的通讯录用户和 userId 时使用;输入手机号(--mobile),返回该手机号所属的用户信息,适合从电话或名片信息定位到具体员工。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true}, + }, + Tips: []string{ + `dws contact +search-mobile --mobile 13800138000`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{ + "mobile": rt.Str("mobile"), + }) + if err != nil { + return err + } + users := searchUserProject(data) + return rt.Output(map[string]any{"count": len(users), "users": users}) + }, +} + +// GetUser 批量获取用户详情(组织管理信息:部门、主管、管理员权限)。 +// ListRoles 获取企业所有角色(标签)列表。 +var ListRoles = shortcut.Shortcut{ + Service: "contact", + Command: "+list-roles", + Product: "contact", + Description: "获取企业所有角色(标签)列表", + Intent: "当你想总览企业里都有哪些角色/员工标签(如「管理员」「财务」「销售」)及其角色 ID 时使用;无需输入,返回全量角色列表,常用于按角色圈定人群前先摸清有哪些角色可选。", + Risk: shortcut.RiskRead, + Tips: []string{ + `dws contact +list-roles`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "get_org_labels", map[string]any{}) + if err != nil { + return err + } + roles := listRolesProject(data) + return rt.Output(map[string]any{"count": len(roles), "roles": roles}) + }, +} + +// listRolesProject reshapes the raw get_org_labels response into a clean +// role/label list ({labelId, labelName}) — clean output projection. +// The list container and field names are probed defensively across candidate +// keys so the projection tolerates response-shape drift. +func listRolesProject(data map[string]any) []map[string]any { + raw := listRolesResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listRolesFirst(m, "labelId", "label_id", "id"); ok { + row["labelId"] = v + } + if v, ok := listRolesFirst(m, "labelName", "label_name", "name"); ok { + row["labelName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listRolesResolveList locates the list payload inside the response, tolerating +// a bare top-level array or nesting under result/data/list/items containers. +func listRolesResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "labels"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + // container may itself wrap the list one level deeper + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "labels", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listRolesFirst returns the first present candidate key's value. +func listRolesFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// SearchRole 根据角色名称精确匹配查询角色(角色ID、名称)。 +// ListRoleMembers 根据角色 ID 查询该角色下的成员列表。 +var ListRoleMembers = shortcut.Shortcut{ + Service: "contact", + Command: "+list-role-members", + Product: "contact", + Description: "查询角色下的成员列表", + Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "角色 ID", Required: true}, + }, + Tips: []string{ + `dws contact +list-role-members --id 12345`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "get_label_members_by_labelId", map[string]any{ + "labelId": rt.Str("id"), + }) + if err != nil { + return err + } + members := memberListProject(data) + return rt.Output(map[string]any{"count": len(members), "members": members}) + }, +} + +// memberListProject reshapes a user/member list response into a clean +// {userId, name} list — clean output projection. Both the list +// container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. Shared by role-member and dept-member listings. +func memberListProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + raw := memberListFindList(data) + if raw == nil { + for _, container := range []string{"result", "data"} { + if inner, ok := data[container].(map[string]any); ok { + if r := memberListFindList(inner); r != nil { + raw = r + break + } + } + } + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil { + row["userId"] = v + } + if v := memberListFirst(m, "name", "userName", "user_name", "flowerName"); v != nil { + row["name"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// memberListFindList returns the first slice found under the common list +// container keys, or nil when none is present. +func memberListFindList(m map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "members", "userList"} { + if arr, ok := m[k].([]any); ok { + return arr + } + } + return nil +} + +// memberListFirst returns the value of the first present key among keys. +func memberListFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// SearchDept 按关键词搜索部门。 +// ListSubDepts 查看指定部门的子部门。 +var ListSubDepts = shortcut.Shortcut{ + Service: "contact", + Command: "+list-sub-depts", + Product: "contact", + Description: "查看指定部门的子部门", + Intent: "当你想逐层浏览组织架构、查看某个部门下一级的子部门时使用;输入父部门 ID(--dept,根部门为 1),返回其直属子部门列表,可用于自顶向下遍历部门树。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1)", Required: true}, + }, + Tips: []string{ + `dws contact +list-sub-depts --dept 1`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "get_sub_depts_by_dept_id", map[string]any{ + "deptId": rt.Int("dept"), + }) + if err != nil { + return err + } + depts := listSubDeptsProject(data) + return rt.Output(map[string]any{"count": len(depts), "depts": depts}) + }, +} + +// listSubDeptsProject reshapes get_sub_depts_by_dept_id into a clean +// {deptId, deptName} list — clean output projection. Both the list +// container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func listSubDeptsProject(data map[string]any) []map[string]any { + if data == nil { + return []map[string]any{} + } + // Locate the list container: it may sit at the top level or be nested one + // level under a common envelope key. + raw := listSubDeptsFindList(data) + if raw == nil { + for _, container := range []string{"result", "data"} { + if inner, ok := data[container].(map[string]any); ok { + if r := listSubDeptsFindList(inner); r != nil { + raw = r + break + } + } + } + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := listSubDeptsFirst(m, "deptId", "dept_id", "id"); v != nil { + row["deptId"] = v + } + if v := listSubDeptsFirst(m, "deptName", "dept_name", "name"); v != nil { + row["deptName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listSubDeptsFindList returns the first slice found under the common list +// container keys, or nil when none is present. +func listSubDeptsFindList(m map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items"} { + if arr, ok := m[k].([]any); ok { + return arr + } + } + return nil +} + +// listSubDeptsFirst returns the value of the first present key among keys. +func listSubDeptsFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// GetDept 获取部门详情(部门 ID、名称、人数)。 +// ListDeptMembers 查看部门成员(仅本部门,不含下级)。 +var ListDeptMembers = shortcut.Shortcut{ + Service: "contact", + Command: "+list-dept-members", + Product: "contact", + Description: "查看部门成员(仅本部门,不含下级)", + Intent: "当你想列出一个或多个部门本级的员工(不含下级子部门)以便群发通知、统计或指派任务时使用;输入部门 ID 列表(--depts,逗号分隔),返回这些部门下的成员,如需含下级需自行遍历子部门。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔", Required: true}, + }, + Tips: []string{ + `dws contact +list-dept-members --depts 12345,67890`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{ + "deptIds": rt.StrSlice("depts"), + }) + if err != nil { + return err + } + members := memberListProject(data) + return rt.Output(map[string]any{"count": len(members), "members": members}) + }, +} + +// ListRosterFields 查询花名册当前用户有权限的字段列表(hrmregister server)。 +var ListRosterFields = shortcut.Shortcut{ + Service: "contact", + Command: "+list-roster-fields", + Product: "hrmregister", + Description: "查询花名册有权限的字段列表", + Intent: "当你要查询花名册(HR 档案)信息、需要先知道当前身份有权访问哪些字段及其字段编码(fieldCode)时使用;无需输入,返回可用字段列表,通常作为调用 +get-roster 前的准备步骤以指定 --fields。", + Risk: shortcut.RiskRead, + Tips: []string{ + `dws contact +list-roster-fields`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_authorized_roster_fields", map[string]any{}) + }, +} + +// GetRoster 查询员工花名册字段信息(个人档案,hrmregister server)。 +var GetRoster = shortcut.Shortcut{ + Service: "contact", + Command: "+get-roster", + Product: "hrmregister", + Description: "查询员工花名册字段信息(学历、家庭、银行卡、合同等)", + Intent: "当你需要查看某员工在 HR 花名册中的详细档案字段(如学历、家庭、银行卡、合同等)时使用;可传员工 ID(--staff-id)和要查的字段编码(--fields,来自 +list-roster-fields),不传则按默认查询,返回授权范围内的花名册信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "staff-id", Type: shortcut.FlagString, Desc: "查询员工 ID(可选)"}, + {Name: "fields", Type: shortcut.FlagStringSlice, Desc: "指定字段集合,逗号分隔,可通过 +list-roster-fields 获取(可选)"}, + }, + Tips: []string{ + `dws contact +get-roster --staff-id STAFF_ID`, + `dws contact +get-roster --staff-id STAFF_ID --fields fieldCode1,fieldCode2`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("staff-id") { + params["staffId"] = rt.Str("staff-id") + } + if rt.Changed("fields") { + params["fieldCodeList"] = rt.StrSlice("fields") + } + return rt.CallMCP("get_authorized_emp_rosterInfo", params) + }, +} + +func init() { + shortcut.Register( + ListFollowings, + SearchUser, + SearchMobile, + ListRoles, + ListRoleMembers, + ListSubDepts, + ListDeptMembers, + ListRosterFields, + GetRoster, + ) +} diff --git a/internal/shortcut/devapp/devapp.go b/internal/shortcut/devapp/devapp.go new file mode 100644 index 00000000..dc9e13f6 --- /dev/null +++ b/internal/shortcut/devapp/devapp.go @@ -0,0 +1,1140 @@ +// 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 devapp declares high-fidelity shortcuts for the DingTalk open-platform +// developer application ("devapp") MCP service, for the apps +// command surface. Tool names and parameter keys are copied verbatim from +// internal/helpers/devapp.go — do not invent tools or params here. +// +// Skipped tools (require async orchestration / polling, not a single MCP call): +// - submit_robot_create_task (异步提交机器人建号任务) +// - query_robot_create_result (异步轮询建号结果) +// +// The robot-config i18n JSON-object params (i18nName/i18nBrief/i18nDescription) +// are also omitted: they need JSON-object parsing that a flat flag can't express. +package devapp + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const productDevApp = "devapp" + +// applyCursor forwards --cursor/--page-size into params, matching the helper's +// pass-through pagination (pageSize defaults to 20, cursor omitted on page 1). +func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) { + if rt.Changed("cursor") { + if cur := rt.Str("cursor"); cur != "" { + params["cursor"] = cur + } + } + size := rt.Int("page-size") + if size < 1 { + size = 20 + } + params["pageSize"] = size +} + +var cursorFlags = []shortcut.Flag{ + {Name: "cursor", Type: shortcut.FlagString, Desc: "游标令牌:首次查询留空,续翻传上次出参的 nextCursor"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "单页条数,默认 20"}, +} + +// --------------------------------------------------------------------------- +// 应用主体 +// --------------------------------------------------------------------------- + +// ListApp maps helper `list_dev_app`. +var ListApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+list", + Product: productDevApp, + Description: "查询开放平台企业内部应用列表", + Intent: "当你要在开发者后台盘点或定位某个企业内部应用(例如按应用名、appKey、创建人或机器人名搜索,拿到其 unifiedAppId 以便后续查看详情、配置或发布)时使用;支持关键词过滤、排序和分页,返回应用列表。", + Risk: shortcut.RiskRead, + Flags: append([]shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "应用名称关键词"}, + {Name: "app-key", Type: shortcut.FlagString, Desc: "按 appKey/clientId 过滤"}, + {Name: "app-group-id", Type: shortcut.FlagInt, Desc: "应用分组 ID"}, + {Name: "creator", Type: shortcut.FlagString, Desc: "创建人名称关键词"}, + {Name: "robot-name", Type: shortcut.FlagString, Desc: "机器人名称关键词"}, + {Name: "develop-type", Type: shortcut.FlagInt, Desc: "开发类型枚举;不确定时不要传"}, + {Name: "filter-cool-app", Type: shortcut.FlagInt, Desc: "酷应用过滤枚举;不确定时不要传"}, + {Name: "sort-type", Type: shortcut.FlagString, Desc: "排序字段,如 gmt_modified"}, + {Name: "sort-order", Type: shortcut.FlagString, Desc: "排序方向 asc 或 desc"}, + }, cursorFlags...), + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + applyCursor(rt, params) + if rt.Changed("name") { + params["name"] = rt.Str("name") + } + if rt.Changed("app-key") { + params["appKey"] = rt.Str("app-key") + } + if rt.Changed("app-group-id") { + params["appGroupId"] = rt.Int("app-group-id") + } + if rt.Changed("creator") { + params["creator"] = rt.Str("creator") + } + if rt.Changed("robot-name") { + params["robotName"] = rt.Str("robot-name") + } + if rt.Changed("develop-type") { + params["developType"] = rt.Int("develop-type") + } + if rt.Changed("filter-cool-app") { + params["filterCoolApp"] = rt.Int("filter-cool-app") + } + if rt.Changed("sort-type") { + params["sortType"] = rt.Str("sort-type") + } + if rt.Changed("sort-order") { + params["sortOrder"] = rt.Str("sort-order") + } + data, err := rt.CallMCPData(productDevApp, "list_dev_app", params) + if err != nil { + return err + } + apps := listAppProject(data) + return rt.Output(map[string]any{"count": len(apps), "apps": apps}) + }, +} + +// listAppProject reshapes list_dev_app into a clean app list +// ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection +// clean output projection. The list container and per-item field names are probed +// defensively across candidate keys, so an unknown/empty shape yields an empty +// list rather than a crash or fabricated data. +func listAppProject(data map[string]any) []map[string]any { + raw := listAppFindList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok { + row["unifiedAppId"] = v + } + if v, ok := listAppFirst(m, "name", "appName", "app_name"); ok { + row["name"] = v + } + if v, ok := listAppFirst(m, "appKey", "clientId", "app_key", "client_id"); ok { + row["appKey"] = v + } + if v, ok := listAppFirst(m, "agentId", "agent_id"); ok { + row["agentId"] = v + } + if v, ok := listAppFirst(m, "status", "appStatus", "app_status"); ok { + row["status"] = v + } + if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { + row["gmtModified"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listAppFindList locates the app list payload, tolerating a bare top-level +// array or nesting one level under a common envelope key. +func listAppFindList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listAppFirst returns the first present candidate key's value. +func listAppFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// GetApp maps helper `get_dev_app`. +var GetApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+get", + Product: productDevApp, + Description: "查询开放平台企业内部应用详情", + Intent: "当你已知某应用的 unifiedAppId、需要查看它的完整配置信息(如名称、描述、图标、能力开关等)以便核对现状或作为修改前的依据时使用;输入 unifiedAppId,返回单个应用的详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// CreateApp maps helper `create_dev_app`. +var CreateApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+create", + Product: productDevApp, + Description: "创建开放平台企业内部应用", + Intent: "当你要在开放平台从零新建一个企业内部应用(H5/机器人等的载体)时使用;传入应用名称、可选描述与图标 mediaId,会实际创建出一个新应用并返回其 unifiedAppId 供后续配置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "应用名称", Required: true}, + {Name: "desc", Type: shortcut.FlagString, Desc: "应用描述"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "应用图标 mediaId"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"name": rt.Str("name")} + if rt.Changed("desc") { + params["desc"] = rt.Str("desc") + } + if rt.Changed("icon-media-id") { + params["iconMediaId"] = rt.Str("icon-media-id") + } + return rt.CallMCP("create_dev_app", params) + }, +} + +// UpdateApp maps helper `update_dev_app`. +var UpdateApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+update", + Product: productDevApp, + Description: "修改开放平台企业内部应用基础信息", + Intent: "当你要改动一个已存在应用的基础信息(更名、改描述或换图标)时使用;指定 unifiedAppId 及要更新的字段,会实际写回并覆盖对应的应用基础资料。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("name") { + params["name"] = rt.Str("name") + } + if rt.Changed("desc") { + params["desc"] = rt.Str("desc") + } + if rt.Changed("icon-media-id") { + params["iconMediaId"] = rt.Str("icon-media-id") + } + return rt.CallMCP("update_dev_app", params) + }, +} + +// DeleteApp maps helper `delete_dev_app`. +var DeleteApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+delete", + Product: productDevApp, + Description: "删除开放平台企业内部应用(不可逆)", + Intent: "当你确认要彻底废弃某个企业内部应用时使用;传入 unifiedAppId 会真实且不可逆地删除该应用及其配置,执行前务必确认无误。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("delete_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// EnableApp maps helper `enable_dev_app`. +var EnableApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+enable", + Product: productDevApp, + Description: "启用开放平台企业内部应用", + Intent: "当某个应用处于停用状态、你要让它重新生效可用时使用;传入 unifiedAppId 会实际将应用状态切换为启用。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("enable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// DisableApp maps helper `disable_dev_app`. +var DisableApp = shortcut.Shortcut{ + Service: "devapp", + Command: "+disable", + Product: productDevApp, + Description: "停用开放平台企业内部应用", + Intent: "当你要临时下线某个应用、让它对用户不可用又不删除时使用;传入 unifiedAppId 会实际将应用状态切换为停用,可日后再启用恢复。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("disable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// GetCredentials maps helper `get_dev_app_credentials`. +var GetCredentials = shortcut.Shortcut{ + Service: "devapp", + Command: "+credentials-get", + Product: productDevApp, + Description: "读取开放平台应用凭证", + Intent: "当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_dev_app_credentials", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// --------------------------------------------------------------------------- +// 网页应用配置 +// --------------------------------------------------------------------------- + +// WebappGet maps helper `get_extension_webapp_config`. +var WebappGet = shortcut.Shortcut{ + Service: "devapp", + Command: "+webapp-get", + Product: productDevApp, + Description: "查询网页应用配置", + Intent: "当你要查看某应用的网页(H5)能力现状,如移动端/PC 首页地址、管理后台地址等,以便核对或作为改配置前的参考时使用;输入 unifiedAppId,返回当前网页应用配置。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_extension_webapp_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// WebappConfig maps helper `set_extension_webapp_config`. +var WebappConfig = shortcut.Shortcut{ + Service: "devapp", + Command: "+webapp-config", + Product: productDevApp, + Description: "配置网页应用能力", + Intent: "当你要为应用开通或调整网页(H5)入口,如设置移动端/PC 端首页 URL、管理后台地址或页面类型时使用;指定 unifiedAppId 及相应地址,会实际写入该应用的网页应用配置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型"}, + {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址"}, + {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址"}, + {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("h5-page-type") { + params["h5PageType"] = rt.Str("h5-page-type") + } + if rt.Changed("homepage-url") { + params["homepageUrl"] = rt.Str("homepage-url") + } + if rt.Changed("pc-homepage-url") { + params["pcHomepageUrl"] = rt.Str("pc-homepage-url") + } + if rt.Changed("omp-url") { + params["ompUrl"] = rt.Str("omp-url") + } + return rt.CallMCP("set_extension_webapp_config", params) + }, +} + +// --------------------------------------------------------------------------- +// 权限 +// --------------------------------------------------------------------------- + +// PermissionList maps helper `list_dev_app_permissions`. +var PermissionList = shortcut.Shortcut{ + Service: "devapp", + Command: "+permission-list", + Product: productDevApp, + Description: "查询开放平台应用权限列表", + Intent: "当你要查看某应用已申请/可申请的 API 权限点及其授权状态(用于排查接口报权限错、或确认某 scopeValue 是否已开通)时使用;可按关键词、scopeValue、授权状态等过滤,返回权限点列表。", + Risk: shortcut.RiskRead, + Flags: append([]shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "权限名、权限点、接口名关键词"}, + {Name: "scope-value", Type: shortcut.FlagString, Desc: "精确权限点 scopeValue"}, + {Name: "auth-status", Type: shortcut.FlagString, Default: "ALL", Desc: "权限状态:ALL、AUTHED、UNAUTHED"}, + {Name: "scope-type", Type: shortcut.FlagString, Desc: "权限一级类型:APP 或 SNS"}, + {Name: "api-status", Type: shortcut.FlagString, Desc: "开发者后台 apiStatus 过滤"}, + }, cursorFlags...), + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + if rt.Changed("scope-value") { + params["scopeValue"] = rt.Str("scope-value") + } + if v := strings.ToUpper(rt.Str("auth-status")); v != "" { + params["authStatus"] = v + } + if rt.Changed("scope-type") { + params["scopeType"] = strings.ToUpper(rt.Str("scope-type")) + } + if rt.Changed("api-status") { + params["apiStatus"] = rt.Str("api-status") + } + applyCursor(rt, params) + data, err := rt.CallMCPData(productDevApp, "list_dev_app_permissions", params) + if err != nil { + return err + } + permissions := permissionListProject(data) + return rt.Output(map[string]any{"count": len(permissions), "permissions": permissions}) + }, +} + +// permissionListProject reshapes list_dev_app_permissions into a clean +// permission-point list ({scopeValue, scopeName, apiName, authStatus, scopeType}) +// — clean output projection. The list container and per-item field +// names are probed defensively across candidate keys, so an unknown/empty shape +// yields an empty list rather than a crash or fabricated data. +func permissionListProject(data map[string]any) []map[string]any { + raw := permissionListFindList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := permissionListFirst(m, "scopeValue", "scope_value", "permissionCode", "code"); ok { + row["scopeValue"] = v + } + if v, ok := permissionListFirst(m, "scopeName", "scope_name", "permissionName", "name"); ok { + row["scopeName"] = v + } + if v, ok := permissionListFirst(m, "apiName", "api_name", "interfaceName"); ok { + row["apiName"] = v + } + if v, ok := permissionListFirst(m, "authStatus", "auth_status", "status"); ok { + row["authStatus"] = v + } + if v, ok := permissionListFirst(m, "scopeType", "scope_type"); ok { + row["scopeType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// permissionListFindList locates the permission list payload, tolerating a bare +// top-level array or nesting one level under a common envelope key. +func permissionListFindList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, k := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// permissionListFirst returns the first present candidate key's value. +func permissionListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// PermissionAdd maps helper `apply_dev_app_permissions`. +var PermissionAdd = shortcut.Shortcut{ + Service: "devapp", + Command: "+permission-add", + Product: productDevApp, + Description: "申请开放平台应用权限点", + Intent: "当应用调用某接口报缺少权限、你要为它开通一批 API 权限点时使用;传入 unifiedAppId 和 scopeValue 列表,会实际为该应用申请/授予这些权限。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "scope-values", Type: shortcut.FlagStringSlice, Desc: "权限点 scopeValue 列表", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "scopeValues": rt.StrSlice("scope-values"), + } + return rt.CallMCP("apply_dev_app_permissions", params) + }, +} + +// PermissionRemove maps helper `remove_dev_app_permissions`. +var PermissionRemove = shortcut.Shortcut{ + Service: "devapp", + Command: "+permission-remove", + Product: productDevApp, + Description: "取消开放平台应用权限点", + Intent: "当你要收回应用已开通的某些 API 权限(如安全收敛、下线不再使用的接口)时使用;传入 unifiedAppId 和待取消的 scopeValue 列表,会实际移除这些权限授权。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "scope-values", Type: shortcut.FlagStringSlice, Desc: "待取消权限点 scopeValue 列表", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "scopeValues": rt.StrSlice("scope-values"), + } + return rt.CallMCP("remove_dev_app_permissions", params) + }, +} + +// --------------------------------------------------------------------------- +// 成员 +// --------------------------------------------------------------------------- + +// MemberList maps helper `list_dev_app_members`. +var MemberList = shortcut.Shortcut{ + Service: "devapp", + Command: "+member-list", + Product: productDevApp, + Description: "查询开放平台应用成员", + Intent: "当你要查看某应用有哪些成员及其角色(如谁是开发者/管理员),用于核对协作人员或权限归属时使用;输入 unifiedAppId,返回成员列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// MemberAdd maps helper `add_dev_app_members`. +var MemberAdd = shortcut.Shortcut{ + Service: "devapp", + Command: "+member-add", + Product: productDevApp, + Description: "添加开放平台应用成员", + Intent: "当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "userIds": rt.StrSlice("user-ids"), + "memberType": rt.Str("member-type"), + } + return rt.CallMCP("add_dev_app_members", params) + }, +} + +// MemberRemove maps helper `remove_dev_app_members`. +var MemberRemove = shortcut.Shortcut{ + Service: "devapp", + Command: "+member-remove", + Product: productDevApp, + Description: "移除开放平台应用成员", + Intent: "当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "userIds": rt.StrSlice("user-ids"), + "memberType": rt.Str("member-type"), + } + return rt.CallMCP("remove_dev_app_members", params) + }, +} + +// --------------------------------------------------------------------------- +// 安全配置 +// --------------------------------------------------------------------------- + +// SecurityConfig maps helper `update_dev_app_security_config`. +var SecurityConfig = shortcut.Shortcut{ + Service: "devapp", + Command: "+security-config", + Product: productDevApp, + Description: "更新开放平台应用安全配置(整组覆盖,非追加)", + Intent: "当你要设置应用的安全策略,如出口 IP 白名单、登录重定向 URL、端内免登地址时使用;注意每项传入的是整组值会全量覆盖旧配置(非追加),所以要一次性传全,常用于配置 OAuth 回调或加固网络白名单。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "ip-whitelist", Type: shortcut.FlagStringSlice, Desc: "出口 IP 白名单(整组覆盖)"}, + {Name: "redirect-urls", Type: shortcut.FlagStringSlice, Desc: "登录重定向 URL(整组覆盖)"}, + {Name: "sso-urls", Type: shortcut.FlagStringSlice, Desc: "端内免登地址(整组覆盖)"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("ip-whitelist") { + params["ipWhitelist"] = rt.StrSlice("ip-whitelist") + } + if rt.Changed("redirect-urls") { + params["redirectUrls"] = rt.StrSlice("redirect-urls") + } + if rt.Changed("sso-urls") { + params["ssoUrls"] = rt.StrSlice("sso-urls") + } + return rt.CallMCP("update_dev_app_security_config", params) + }, +} + +// --------------------------------------------------------------------------- +// 机器人能力 +// --------------------------------------------------------------------------- + +// RobotGet maps helper `get_extension_robot_config`. +var RobotGet = shortcut.Shortcut{ + Service: "devapp", + Command: "+robot-get", + Product: productDevApp, + Description: "查询现有应用的机器人配置", + Intent: "当你要查看某应用已有的机器人配置(名称、回调地址、模式 HTTPS/STREAM/AISKILL、技能等)以核对现状或作为改配置前的依据时使用;输入 unifiedAppId,返回当前机器人配置。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_extension_robot_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// RobotConfig maps helper `set_extension_robot_config` (upsert). The i18n +// JSON-object params are intentionally omitted (see package doc). +var RobotConfig = shortcut.Shortcut{ + Service: "devapp", + Command: "+robot-config", + Product: productDevApp, + Description: "创建或更新现有应用的机器人配置(upsert)", + Intent: "当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称"}, + {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId"}, + {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址"}, + {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址"}, + {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL"}, + {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表"}, + {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限"}, + {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("name") { + params["name"] = rt.Str("name") + } + if rt.Changed("brief") { + params["brief"] = rt.Str("brief") + } + if rt.Changed("desc") { + params["desc"] = rt.Str("desc") + } + if rt.Changed("icon-media-id") { + params["iconMediaId"] = rt.Str("icon-media-id") + } + if rt.Changed("outgoing-url") { + params["outgoingUrl"] = rt.Str("outgoing-url") + } + if rt.Changed("event-callback-url") { + params["eventCallbackUrl"] = rt.Str("event-callback-url") + } + if rt.Changed("mode") { + params["mode"] = strings.ToUpper(rt.Str("mode")) + } + if rt.Changed("skills") { + params["skills"] = rt.StrSlice("skills") + } + if rt.Changed("add-scope") { + params["addScope"] = rt.Bool("add-scope") + } + if rt.Changed("disable-ssl-verify") { + params["disableSSLVerify"] = rt.Bool("disable-ssl-verify") + } + return rt.CallMCP("set_extension_robot_config", params) + }, +} + +// RobotEnable maps helper `enable_dev_app_robot`. +var RobotEnable = shortcut.Shortcut{ + Service: "devapp", + Command: "+robot-enable", + Product: productDevApp, + Description: "启用现有应用机器人能力(纯启用,无需配置字段)", + Intent: "当应用的机器人能力已配置但处于关闭状态、你只想把它打开生效时使用;仅传 unifiedAppId 即可,会实际启用该应用的机器人能力,无需再传配置字段。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("enable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// RobotDisable maps helper `disable_dev_app_robot`. +var RobotDisable = shortcut.Shortcut{ + Service: "devapp", + Command: "+robot-disable", + Product: productDevApp, + Description: "停用现有应用的机器人能力", + Intent: "当你要临时关闭某应用的机器人(不再收发机器人消息)但保留其配置时使用;传入 unifiedAppId 会实际停用机器人能力,可日后再启用恢复。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("disable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + }, +} + +// --------------------------------------------------------------------------- +// 事件订阅 +// --------------------------------------------------------------------------- + +// EventList maps helper `list_dev_app_events`. +var EventList = shortcut.Shortcut{ + Service: "devapp", + Command: "+event-list", + Product: productDevApp, + Description: "查询应用已订阅的事件列表", + Intent: "当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。", + Risk: shortcut.RiskRead, + Flags: append([]shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "事件搜索关键词,支持按事件码或事件名称模糊匹配"}, + }, cursorFlags...), + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + applyCursor(rt, params) + data, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params) + if err != nil { + return err + } + events := eventListProject(data) + return rt.Output(map[string]any{"count": len(events), "events": events}) + }, +} + +// eventListProject reshapes list_dev_app_events into a clean subscribed-event +// list ({eventCode, eventName, status, gmtModified}) — output-projection +// clean output projection. The list container and per-item field names are probed +// defensively across candidate keys, so an unknown/empty shape yields an empty +// list rather than a crash or fabricated data. +func eventListProject(data map[string]any) []map[string]any { + raw := eventListFindList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := eventListFirst(m, "eventCode", "event_code", "code"); ok { + row["eventCode"] = v + } + if v, ok := eventListFirst(m, "eventName", "event_name", "name"); ok { + row["eventName"] = v + } + if v, ok := eventListFirst(m, "status", "subscribeStatus", "subscribe_status"); ok { + row["status"] = v + } + if v, ok := eventListFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { + row["gmtModified"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// eventListFindList locates the event list payload, tolerating a bare top-level +// array or nesting one level under a common envelope key. +func eventListFindList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, k := range []string{"list", "items", "events", "eventList", "result", "data"} { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "events", "eventList", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// eventListFirst returns the first present candidate key's value. +func eventListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// EventSubscribe maps helper `subscribe_dev_app_events`. +var EventSubscribe = shortcut.Shortcut{ + Service: "devapp", + Command: "+event-subscribe", + Product: productDevApp, + Description: "订阅应用事件回调", + Intent: "当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "eventCodes": rt.StrSlice("event-codes"), + } + return rt.CallMCP("subscribe_dev_app_events", params) + }, +} + +// EventUnsubscribe maps helper `unsubscribe_dev_app_events`. +var EventUnsubscribe = shortcut.Shortcut{ + Service: "devapp", + Command: "+event-unsubscribe", + Product: productDevApp, + Description: "取消订阅应用事件", + Intent: "当你不再需要某些事件的回调推送、要停止接收它们时使用;传入 unifiedAppId 和事件码列表,会实际取消这些事件的订阅。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "eventCodes": rt.StrSlice("event-codes"), + } + return rt.CallMCP("unsubscribe_dev_app_events", params) + }, +} + +// --------------------------------------------------------------------------- +// 版本发布 +// --------------------------------------------------------------------------- + +// VersionCreate maps helper `create_dev_app_version`. +var VersionCreate = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-create", + Product: productDevApp, + Description: "基于当前配置创建应用新版本", + Intent: "当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "version", Type: shortcut.FlagString, Desc: "高级可选:显式版本号,如 1.0.1;默认由服务端自动递增"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "版本描述"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + if rt.Changed("version") { + params["version"] = rt.Str("version") + } + if rt.Changed("desc") { + params["desc"] = rt.Str("desc") + } + return rt.CallMCP("create_dev_app_version", params) + }, +} + +// VersionList maps helper `list_dev_app_versions`. +var VersionList = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-list", + Product: productDevApp, + Description: "分页查询应用版本列表", + Intent: "当你要查看某应用的历史版本(找某个 versionId、看各版本发布状态或回顾迭代记录)时使用;输入 unifiedAppId 并分页,返回版本列表。", + Risk: shortcut.RiskRead, + Flags: append([]shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + }, cursorFlags...), + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + applyCursor(rt, params) + data, err := rt.CallMCPData(productDevApp, "list_dev_app_versions", params) + if err != nil { + return err + } + versions := versionListProject(data) + return rt.Output(map[string]any{"count": len(versions), "versions": versions}) + }, +} + +// versionListProject reshapes list_dev_app_versions into a clean version list +// ({versionId, version, status, desc, gmtCreate}) — output-projection fidelity +// for clean output. The list container and per-item field names are probed defensively +// across candidate keys, so an unknown/empty shape yields an empty list rather +// than a crash or fabricated data. +func versionListProject(data map[string]any) []map[string]any { + raw := versionListFindList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := versionListFirst(m, "versionId", "version_id", "id"); ok { + row["versionId"] = v + } + if v, ok := versionListFirst(m, "version", "versionName", "version_name"); ok { + row["version"] = v + } + if v, ok := versionListFirst(m, "status", "publishStatus", "publish_status", "versionStatus"); ok { + row["status"] = v + } + if v, ok := versionListFirst(m, "desc", "description", "remark"); ok { + row["desc"] = v + } + if v, ok := versionListFirst(m, "gmtCreate", "gmt_create", "createTime", "create_time"); ok { + row["gmtCreate"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// versionListFindList locates the version list payload, tolerating a bare +// top-level array or nesting one level under a common envelope key. +func versionListFindList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, k := range []string{"list", "items", "versions", "versionList", "result", "data"} { + v, ok := data[k] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "versions", "versionList", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// versionListFirst returns the first present candidate key's value. +func versionListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// VersionGet maps helper `get_dev_app_version_detail`. +var VersionGet = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-get", + Product: productDevApp, + Description: "查询指定版本详情", + Intent: "当你已知某个 versionId、要查看该版本的具体内容(版本号、描述、包含的配置等)以核对发布内容时使用;输入 unifiedAppId 和 versionId,返回单个版本的详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "versionId": rt.Str("version-id"), + } + return rt.CallMCP("get_dev_app_version_detail", params) + }, +} + +// VersionCheckApproval maps helper `publish_dev_app_version` in precheck mode +// (precheckOnly=true): it only returns approval requirements, does not publish. +var VersionCheckApproval = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-check-approval", + Product: productDevApp, + Description: "预检版本发布是否需要审批(不实际发布)", + Intent: "当你在正式发布某版本前想先确认它是否会触发审批、是否含高敏权限等发布前置要求时使用;传入 unifiedAppId 和 versionId,仅做预检返回审批要求,不会真正发布。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "versionId": rt.Str("version-id"), + "precheckOnly": true, + } + return rt.CallMCP("publish_dev_app_version", params) + }, +} + +// VersionPublish maps helper `publish_dev_app_version` (real publish, +// precheckOnly=false). +var VersionPublish = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-publish", + Product: productDevApp, + Description: "发布指定版本(含高敏权限需 --confirmed-sensitive)", + Intent: "当你要把某个已创建的版本正式上线到线上环境时使用;传入 unifiedAppId 和 versionId,会实际触发发布(含高敏权限需加 --confirmed-sensitive 确认,灰度选人可指定审批人),可能进入审批流。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, + {Name: "confirmed-sensitive", Type: shortcut.FlagBool, Desc: "确认发布包含高敏权限的版本"}, + {Name: "approver-user-id", Type: shortcut.FlagString, Desc: "灰度选人模式下指定审批人 userId"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "versionId": rt.Str("version-id"), + "precheckOnly": false, + } + if rt.Changed("confirmed-sensitive") { + params["confirmedSensitive"] = rt.Bool("confirmed-sensitive") + } + if rt.Changed("approver-user-id") { + params["approverUserId"] = rt.Str("approver-user-id") + } + return rt.CallMCP("publish_dev_app_version", params) + }, +} + +// VersionStatus maps helper `get_dev_app_version_status`. +var VersionStatus = shortcut.Shortcut{ + Service: "devapp", + Command: "+version-status", + Product: productDevApp, + Description: "查询版本发布/审批状态", + Intent: "当你已提交发布、想跟进某版本当前处于什么阶段(审批中、已发布、被驳回等)时使用;输入 unifiedAppId 和 versionId,返回该版本的发布/审批状态。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "unifiedAppId": rt.Str("unified-app-id"), + "versionId": rt.Str("version-id"), + } + return rt.CallMCP("get_dev_app_version_status", params) + }, +} + +func init() { + shortcut.Register( + ListApp, + GetApp, + CreateApp, + UpdateApp, + DeleteApp, + EnableApp, + DisableApp, + GetCredentials, + WebappGet, + WebappConfig, + PermissionList, + PermissionAdd, + PermissionRemove, + MemberList, + MemberAdd, + MemberRemove, + SecurityConfig, + RobotGet, + RobotConfig, + RobotEnable, + RobotDisable, + EventList, + EventSubscribe, + EventUnsubscribe, + VersionCreate, + VersionList, + VersionGet, + VersionCheckApproval, + VersionPublish, + VersionStatus, + ) +} diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go new file mode 100644 index 00000000..47061216 --- /dev/null +++ b/internal/shortcut/ding/ding.go @@ -0,0 +1,117 @@ +// 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 ding holds the built-in DING (钉) shortcuts. Tool names and params are +// copied verbatim from internal/helpers/ding.go. Some tools route to the "im" +// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product. +package ding + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + +var List = shortcut.Shortcut{ + Service: "ding", Command: "+list", Product: "im", + Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Desc: "类型 (可选)"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"type": rt.Str("type")} + if rt.Changed("cursor") { + params["cursor"] = rt.Int("cursor") + } + return rt.CallMCP("list_ding_messages", params) + }, +} + +var ReceiverStatus = shortcut.Shortcut{ + Service: "ding", Command: "+receiver-status", Product: "im", + Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("list_ding_receiver_status", map[string]any{ + "openDingId": rt.Str("ding-id"), + }) + }, +} + +var SendPersonal = shortcut.Shortcut{ + Service: "ding", Command: "+send-personal", Product: "im", + Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "receiverOpenDingTalkIds": rt.StrSlice("users"), + "content": rt.Str("content"), + "remindType": rt.Str("type"), + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("send_personal_ding", params) + }, +} + +var SendByMessage = shortcut.Shortcut{ + Service: "ding", Command: "+send-by-message", Product: "im", + Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "openConversationId": rt.Str("group"), + "openMessageId": rt.Str("message-id"), + "receiverOpenDingTalkIds": rt.StrSlice("users"), + "remindType": rt.Str("type"), + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("send_ding_by_message", params) + }, +} + +var RecallPersonal = shortcut.Shortcut{ + Service: "ding", Command: "+recall-personal", Product: "im", + Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_personal_ding", map[string]any{ + "openDingId": rt.Str("id"), + }) + }, +} + +func init() { + shortcut.Register( + List, + ReceiverStatus, + SendPersonal, + SendByMessage, + RecallPersonal, + ) +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go new file mode 100644 index 00000000..f81aee66 --- /dev/null +++ b/internal/shortcut/doc/doc.go @@ -0,0 +1,642 @@ +// 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 doc declares the high-fidelity `dws doc +` shortcuts. +// Tool names and parameter keys are lifted verbatim from +// internal/helpers/doc.go (the single source of truth for DingTalk MCP tools). +package doc + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + productDoc = "doc" + productComment = "doc-comment" +) + +// ── 文档浏览 / 读取 ────────────────────────────────────────── + +var Search = shortcut.Shortcut{ + Service: "doc", + Command: "+search", + Product: productDoc, + Description: "按关键词搜索有权限的文档 (不传则返回最近访问)", + Intent: "当你只记得文档的标题或主题词、需要先定位到某篇钉钉文档拿到它的 nodeId/URL 以便后续阅读或编辑时使用;可按关键词、扩展名、创建/访问时间、创建者等条件过滤,不传关键词则返回最近访问的文档,返回匹配的文档列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词,不传返回最近访问的文档"}, + {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "按文件扩展名过滤 (如 adoc,axls,pdf)"}, + {Name: "created-from", Type: shortcut.FlagInt, Desc: "创建时间起始 (毫秒时间戳)"}, + {Name: "created-to", Type: shortcut.FlagInt, Desc: "创建时间截止 (毫秒时间戳)"}, + {Name: "visited-from", Type: shortcut.FlagInt, Desc: "访问时间起始 (毫秒时间戳)"}, + {Name: "visited-to", Type: shortcut.FlagInt, Desc: "访问时间截止 (毫秒时间戳)"}, + {Name: "creator-uids", Type: shortcut.FlagStringSlice, Desc: "按创建者用户 ID 过滤"}, + {Name: "editor-uids", Type: shortcut.FlagStringSlice, Desc: "按编辑者用户 ID 过滤"}, + {Name: "mentioned-uids", Type: shortcut.FlagStringSlice, Desc: "按 @提及的用户 ID 过滤"}, + {Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"}, + }, + Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if v := rt.Str("query"); v != "" { + params["keyword"] = v + } + if rt.Changed("extensions") { + params["extensions"] = rt.StrSlice("extensions") + } + if rt.Changed("created-from") { + params["createdTimeFrom"] = rt.Int("created-from") + } + if rt.Changed("created-to") { + params["createdTimeTo"] = rt.Int("created-to") + } + if rt.Changed("visited-from") { + params["visitedTimeFrom"] = rt.Int("visited-from") + } + if rt.Changed("visited-to") { + params["visitedTimeTo"] = rt.Int("visited-to") + } + if rt.Changed("creator-uids") { + params["creatorUserIds"] = rt.StrSlice("creator-uids") + } + if rt.Changed("editor-uids") { + params["editorUserIds"] = rt.StrSlice("editor-uids") + } + if rt.Changed("mentioned-uids") { + params["mentionedUserIds"] = rt.StrSlice("mentioned-uids") + } + if rt.Changed("workspace-ids") { + params["workspaceIds"] = rt.StrSlice("workspace-ids") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData(productDoc, "search_documents", params) + if err != nil { + return err + } + docs := searchDocsProject(data) + return rt.Output(map[string]any{"count": len(docs), "documents": docs}) + }, +} + +// searchDocsProject reshapes the raw search_documents response into a clean +// document list ({nodeId, name, docType, url, creatorId, modifiedTime}) — +// clean output projection. Both the list container and per-item +// field names are probed defensively across candidate keys so response-shape +// drift yields an empty/partial list rather than a crash or fabricated data. +func searchDocsProject(data map[string]any) []map[string]any { + raw := docResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := docFirst(m, "nodeId", "node_id", "id", "docId", "doc_id"); ok { + row["nodeId"] = v + } + if v, ok := docFirst(m, "name", "title", "docName", "fileName"); ok { + row["name"] = v + } + if v, ok := docFirst(m, "docType", "doc_type", "type", "extension", "fileType"); ok { + row["docType"] = v + } + if v, ok := docFirst(m, "url", "docUrl", "nodeUrl", "webUrl"); ok { + row["url"] = v + } + if v, ok := docFirst(m, "creatorId", "creatorUserId", "creator_user_id", "creator"); ok { + row["creatorId"] = v + } + if v, ok := docFirst(m, "modifiedTime", "gmtModified", "visitedTime", "updateTime", "modifyTime"); ok { + row["modifiedTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// docResolveList locates the list payload inside a doc-service response, +// tolerating a bare top-level array or nesting under common envelope keys, and +// optionally one level deeper inside a result/data container. +func docResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"nodes", "documents", "list", "items", "result", "data", "records"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"nodes", "documents", "list", "items", "records", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// docFirst returns the first present candidate key's value. +func docFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +var List = shortcut.Shortcut{ + Service: "doc", + Command: "+list", + Product: productDoc, + Description: "列出文件夹或知识库下的直接子节点", + Intent: "当你已知某个文档文件夹或知识库的 ID、想浏览它下面直接包含的文档与子文件夹(不递归深层)以便逐层导航时使用;输入 folder 或 workspace,返回该层级的子节点列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "folder", Type: shortcut.FlagString, Desc: "文档文件夹 nodeId 或 alidocs 文件夹 URL"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"}, + }, + Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData(productDoc, "list_nodes", params) + if err != nil { + return err + } + nodes := listNodesProject(data) + return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes}) + }, +} + +// listNodesProject reshapes the raw list_nodes response into a clean child-node +// list ({nodeId, name, nodeType, url}) — clean output projection. +// The list container and per-item field names are probed defensively via the +// shared docResolveList/docFirst helpers, so an unknown shape yields an empty +// list rather than a crash or fabricated data. +func listNodesProject(data map[string]any) []map[string]any { + raw := docResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := docFirst(m, "nodeId", "node_id", "id", "docId", "doc_id"); ok { + row["nodeId"] = v + } + if v, ok := docFirst(m, "name", "title", "nodeName", "fileName"); ok { + row["name"] = v + } + if v, ok := docFirst(m, "nodeType", "node_type", "docType", "type", "extension"); ok { + row["nodeType"] = v + } + if v, ok := docFirst(m, "url", "nodeUrl", "docUrl", "webUrl"); ok { + row["url"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ── 文档创建 / 更新 ────────────────────────────────────────── + +var Copy = shortcut.Shortcut{ + Service: "doc", + Command: "+copy", + Product: productDoc, + Description: "复制文档/文件到指定文件夹或知识库", + Intent: "当你想保留原件、在另一个文件夹或知识库里生成一份文档/文件副本(例如以某篇文档为模板另存)时使用;输入源 node 与目标 folder/workspace,会实际创建一个副本。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档/文件 ID 或 URL", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 nodeId 或 alidocs 文件夹 URL"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + }, + Tips: []string{`dws doc +copy --node DOC_ID --folder TARGET_FOLDER_NODE_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + return rt.CallMCP("copy_document", params) + }, +} + +var Move = shortcut.Shortcut{ + Service: "doc", + Command: "+move", + Product: productDoc, + Description: "移动文档/文件到指定文件夹或知识库", + Intent: "当你要整理文档归属、把某篇文档/文件从当前位置挪到另一个文件夹或知识库(原位置不再保留)时使用;输入 node 与目标 folder/workspace,会实际改变文件的存放位置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档/文件 ID 或 URL", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 nodeId 或 alidocs 文件夹 URL"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + }, + Tips: []string{`dws doc +move --node DOC_ID --folder TARGET_FOLDER_NODE_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + return rt.CallMCP("move_document", params) + }, +} + +// ── 文件 / 文件夹 ──────────────────────────────────────────── + +// ── 块级编辑 ───────────────────────────────────────────────── + +// ── 文档附件 ───────────────────────────────────────────────── + +// ── 文档评论 (server: doc-comment) ─────────────────────────── + +var CommentList = shortcut.Shortcut{ + Service: "doc", + Command: "+comment-list", + Product: productComment, + Description: "查询文档评论列表", + Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"}, + {Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}}, + {Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}}, + }, + Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + if v := rt.Str("cursor"); v != "" { + params["nextToken"] = v + } + if v := rt.Str("type"); v != "" { + params["commentType"] = v + } + if v := rt.Str("resolve-status"); v != "" { + params["resolveStatus"] = v + } + return rt.CallMCP("list_comments", params) + }, +} + +var CommentCreate = shortcut.Shortcut{ + Service: "doc", + Command: "+comment-create", + Product: productComment, + Description: "在文档上创建一条评论", + Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, + }, + Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "nodeId": rt.Str("node"), + "content": rt.Str("content"), + } + if rt.Changed("mention") { + params["mentionedUserIds"] = rt.StrSlice("mention") + } + return rt.CallMCP("create_comment", params) + }, +} + +var CommentReply = shortcut.Shortcut{ + Service: "doc", + Command: "+comment-reply", + Product: productComment, + Description: "回复文档中的一条评论", + Intent: "当你要针对某条已有评论进行回复、参与讨论或用表情贴图回应时使用;先从评论列表拿到 comment-key,再输入 node、comment-key 与 content(--emoji 则作为表情回复),会实际发布一条回复。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true}, + {Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true}, + {Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, + }, + Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "nodeId": rt.Str("node"), + "content": rt.Str("content"), + "replyCommentKey": rt.Str("comment-key"), + } + if rt.Bool("emoji") { + params["emoji"] = true + } + if rt.Changed("mention") { + params["mentionedUserIds"] = rt.StrSlice("mention") + } + return rt.CallMCP("reply_comment", params) + }, +} + +var CommentCreateInline = shortcut.Shortcut{ + Service: "doc", + Command: "+comment-create-inline", + Product: productComment, + Description: "在文档选中文本区域上创建划词评论", + Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true}, + {Name: "block-id", Type: shortcut.FlagString, Desc: "评论标记所在的块 ID (通过 +block-list 获取)", Required: true}, + {Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true}, + {Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true}, + {Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, + }, + Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "nodeId": rt.Str("node"), + "content": rt.Str("content"), + "blockId": rt.Str("block-id"), + "start": rt.Int("start"), + "end": rt.Int("end"), + } + if v := rt.Str("selected-text"); v != "" { + params["selectedText"] = v + } + if rt.Changed("mention") { + params["mentionedUserIds"] = rt.StrSlice("mention") + } + return rt.CallMCP("create_inline_comment", params) + }, +} + +// ── 协作权限 ───────────────────────────────────────────────── + +// ── 导出 ───────────────────────────────────────────────────── + +var ExportSubmit = shortcut.Shortcut{ + Service: "doc", + Command: "+export-submit", + Product: productDoc, + Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId", + Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true}, + {Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}}, + }, + Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`}, + Execute: func(rt *shortcut.RuntimeContext) error { + format := rt.Str("export-format") + if format == "" { + format = "docx" + } + return rt.CallMCP("submit_export_job", map[string]any{ + "nodeId": rt.Str("node"), + "exportFormat": format, + }) + }, +} + +var ExportGet = shortcut.Shortcut{ + Service: "doc", + Command: "+export-get", + Product: productDoc, + Description: "根据 jobId 查询文档导出任务结果", + Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true}, + }, + Tips: []string{`dws doc +export-get --job-id JOB_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")}) + }, +} + +// ── 历史版本 (server: doc) ─────────────────────────────────── + +var VersionSave = shortcut.Shortcut{ + Service: "doc", + Command: "+version-save", + Product: productDoc, + Description: "手动保存文档版本快照", + Intent: "当你在做重大改动前后、想手动打一个可回滚的版本存档点时使用;输入 node,会实际为该文档保存一个当前内容的历史版本快照。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + }, + Tips: []string{`dws doc +version-save --node DOC_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("save_doc_version", map[string]any{"nodeId": rt.Str("node")}) + }, +} + +var VersionList = shortcut.Shortcut{ + Service: "doc", + Command: "+version-list", + Product: productDoc, + Description: "查看文档历史版本列表", + Intent: "当你想查看某篇文档有哪些历史版本、以便挑一个版本号用于回滚时使用;输入 node,返回历史版本列表及其版本号。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Tips: []string{`dws doc +version-list --node DOC_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("limit") { + params["maxResults"] = rt.Int("limit") + } + if v := rt.Str("cursor"); v != "" { + params["nextCursor"] = v + } + return rt.CallMCP("list_doc_versions", params) + }, +} + +var VersionRevert = shortcut.Shortcut{ + Service: "doc", + Command: "+version-revert", + Product: productDoc, + Description: "回滚文档到指定历史版本", + Intent: "当文档被误改、你想把它整体恢复到某个历史版本时使用;先用 +version-list 找到目标版本号,再输入 node 与 version,会实际把文档内容覆盖回该版本,属于高风险写操作,需谨慎确认。", + Risk: shortcut.RiskHighWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "version", Type: shortcut.FlagInt, Desc: "目标版本号 (从 +version-list 获取)", Required: true}, + }, + Tips: []string{`dws doc +version-revert --node DOC_ID --version 3 --yes`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("revert_doc_version", map[string]any{ + "nodeId": rt.Str("node"), + "version": rt.Int("version"), + }) + }, +} + +// ── 模板 (server: doc) ─────────────────────────────────────── + +var TemplateList = shortcut.Shortcut{ + Service: "doc", + Command: "+template-list", + Product: productDoc, + Description: "获取文档模板列表", + Intent: "当你想基于模板新建文档、需要先浏览可用的模板(自己的 MY 或公共 PUBLIC)并拿到 templateId 时使用;返回模板列表,随后可配合 +template-apply 套用。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "source", Type: shortcut.FlagString, Desc: "模板来源: MY / PUBLIC (默认 MY)", Enum: []string{"MY", "PUBLIC"}}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回数量上限"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Tips: []string{`dws doc +template-list --source PUBLIC`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if v := rt.Str("source"); v != "" { + params["templateSource"] = v + } + if rt.Changed("limit") { + params["maxResults"] = rt.Int("limit") + } + if v := rt.Str("cursor"); v != "" { + params["nextCursor"] = v + } + return rt.CallMCP("list_doc_templates", params) + }, +} + +var TemplateSearch = shortcut.Shortcut{ + Service: "doc", + Command: "+template-search", + Product: productDoc, + Description: "根据关键词搜索文档模板", + Intent: "当模板较多、你想按关键词(如“周报”“合同”)快速找到合适的模板并拿到 templateId 时使用;输入 query,返回匹配的模板列表,随后可配合 +template-apply 套用。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "source", Type: shortcut.FlagString, Desc: "模板来源: MY / PUBLIC (默认 MY)", Enum: []string{"MY", "PUBLIC"}}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回数量上限"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Tips: []string{`dws doc +template-search --query "周报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"searchName": rt.Str("query")} + if v := rt.Str("source"); v != "" { + params["templateSource"] = v + } + if rt.Changed("limit") { + params["maxResults"] = rt.Int("limit") + } + if v := rt.Str("cursor"); v != "" { + params["nextCursor"] = v + } + return rt.CallMCP("search_doc_templates", params) + }, +} + +var TemplateApply = shortcut.Shortcut{ + Service: "doc", + Command: "+template-apply", + Product: productDoc, + Description: "使用指定模板创建新文档", + Intent: "当你已选定某个模板、想据此快速生成一篇带预设结构的新文档时使用;输入 template-id(可选 name/folder/workspace),会实际按模板创建一篇新文档并返回其 ID。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新文档名称 (可选)"}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID (可选)"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID (可选)"}, + }, + Tips: []string{`dws doc +template-apply --template-id TPL_ID --name "我的周报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"templateId": rt.Str("template-id")} + if v := rt.Str("name"); v != "" { + params["name"] = v + } + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + return rt.CallMCP("apply_doc_template", params) + }, +} + +func init() { + shortcut.Register( + Search, + List, + Copy, + Move, + CommentList, + CommentCreate, + CommentReply, + CommentCreateInline, + ExportSubmit, + ExportGet, + VersionSave, + VersionList, + VersionRevert, + TemplateList, + TemplateSearch, + TemplateApply, + ) +} diff --git a/internal/shortcut/drive/drive.go b/internal/shortcut/drive/drive.go new file mode 100644 index 00000000..68c1bb77 --- /dev/null +++ b/internal/shortcut/drive/drive.go @@ -0,0 +1,535 @@ +// 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 drive declares high-fidelity shortcuts for the DingTalk drive (钉盘) +// service: file/folder listing, metadata, download link, folder creation, +// upload credentials, search, recycle bin, internet publish and the document +// space proxy operations (copy/move/rename/permission/recent). Tool names and +// parameters mirror internal/helpers/drive.go exactly. Tools that live on the +// "doc" MCP server (per callMCPToolOnServer("doc", ...)) set Product: "doc". +package drive + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + +// ── 钉盘文件(drive MCP server)──────────────────────────────── + +// List → list_files +var List = shortcut.Shortcut{ + Service: "drive", + Command: "+list", + Product: "drive", + Description: "列出钉盘文件/文件夹", + Intent: "当你想浏览钉盘某个空间或文件夹下有哪些文件和子文件夹、需要拿到文件的 dentryUuid 以便后续下载/移动/删除时使用;可指定 space-id、folder 逐层进入,支持分页和按创建/修改时间、名称排序,返回文件列表(含 ID、名称、类型等)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID (纯数字),不传则使用「我的文件」"}, + {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID (dentryUuid),不传则列出空间根目录"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量 (默认 20,最大 50)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,首次不传"}, + {Name: "order-by", Type: shortcut.FlagString, Desc: "排序字段: createTime|modifyTime|name"}, + {Name: "order", Type: shortcut.FlagString, Desc: "排序方向: asc|desc (默认 desc)"}, + {Name: "thumbnail", Type: shortcut.FlagBool, Desc: "是否返回缩略图信息"}, + }, + Tips: []string{ + `dws drive +list --limit 20`, + `dws drive +list --folder --order-by name --order asc`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"maxResults": rt.Int("limit")} + if rt.Changed("space-id") { + params["spaceId"] = rt.Str("space-id") + } + if rt.Changed("folder") { + params["parentId"] = rt.Str("folder") + } + if rt.Changed("cursor") { + params["nextToken"] = rt.Str("cursor") + } + if rt.Changed("order-by") { + params["orderBy"] = rt.Str("order-by") + } + if rt.Changed("order") { + params["order"] = rt.Str("order") + } + if rt.Bool("thumbnail") { + params["withThumbnail"] = true + } + data, err := rt.CallMCPData("drive", "list_files", params) + if err != nil { + return err + } + files := listFilesProject(data) + return rt.Output(map[string]any{"count": len(files), "files": files}) + }, +} + +// listFilesProject reshapes the raw list_files response into a clean, +// stable 钉盘 file/folder list ({name,type,dentryId,fileSize}) — the +// output-projection fidelity applied to every list shortcut. Both the list +// container and each field are probed defensively across candidate keys, so a +// missing container or unknown alias simply yields an empty list rather than a +// fabricated value. +func listFilesProject(data map[string]any) []map[string]any { + raw := listFilesContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + listFilesPick(row, m, "name", "name", "fileName", "dentryName", "title") + listFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType") + listFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId") + listFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length") + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listFilesContainer locates the file list array inside the response by trying +// the common container keys emitted across drive backends; the payload itself +// may also already be the array. +func listFilesContainer(data map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} { + if v, ok := data[k].([]any); ok { + return v + } + // The container may be nested one level (e.g. {"data":{"list":[...]}}). + if inner, ok := data[k].(map[string]any); ok { + for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} { + if v, ok := inner[ik].([]any); ok { + return v + } + } + } + } + return nil +} + +// listFilesPick copies the first matching alias from src into dst under the +// canonical key, leaving dst untouched when no alias is present. +func listFilesPick(dst, src map[string]any, canonical string, aliases ...string) { + for _, a := range aliases { + if v, ok := src[a]; ok { + dst[canonical] = v + return + } + } +} + +// Info → get_file_info +var Info = shortcut.Shortcut{ + Service: "drive", + Command: "+info", + Product: "drive", + Description: "获取钉盘文件/文件夹元数据", + Intent: "当你已经知道某个节点的 dentryUuid、想查看它的详细信息(名称、大小、类型、创建/修改时间、所属空间等)而不是仅列表概览时使用;输入 node(节点 ID),返回该单个文件或文件夹的完整元数据。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "节点 ID (dentryUuid)", Required: true}, + {Name: "space-id", Type: shortcut.FlagString, Desc: "节点所属空间 ID"}, + }, + Tips: []string{`dws drive +info --node `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"fileId": rt.Str("node")} + if rt.Changed("space-id") { + params["spaceId"] = rt.Str("space-id") + } + return rt.CallMCP("get_file_info", params) + }, +} + +// Download → download_file (返回下载链接与签名请求头) +var Download = shortcut.Shortcut{ + Service: "drive", + Command: "+download", + Product: "drive", + Description: "获取钉盘文件下载链接", + Intent: "当你需要把钉盘里某个文件下载到本地或转给他人时使用;输入文件的 dentryUuid,返回带签名的临时下载 URL 和请求头,用它去真正拉取文件内容(本命令本身只取链接、不落盘)。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文件 ID (dentryUuid)", Required: true}, + {Name: "space-id", Type: shortcut.FlagString, Desc: "文件所属空间 ID"}, + }, + Tips: []string{`dws drive +download --node `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"fileId": rt.Str("node")} + if rt.Changed("space-id") { + params["spaceId"] = rt.Str("space-id") + } + return rt.CallMCP("download_file", params) + }, +} + +// CreateFolder → create_folder +// UploadInfo → get_upload_info (获取 OSS 上传凭证) +// Commit → commit_upload (OSS 上传完成后提交入库) +// ListSpaces → list_spaces +// Search → search_files +var Search = shortcut.Shortcut{ + Service: "drive", + Command: "+search", + Product: "drive", + Description: "搜索钉盘文件", + Intent: "当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "target", Type: shortcut.FlagString, Desc: "搜索范围: file(钉盘文件) / space(钉盘团队空间)", Enum: []string{"file", "space"}}, + {Name: "file-types", Type: shortcut.FlagStringSlice, Desc: "按文件内容类型过滤: alidoc,document,image,video,audio,archive"}, + {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "按文件扩展名过滤,不含点号 (如 pdf,docx)"}, + {Name: "creator-uids", Type: shortcut.FlagStringSlice, Desc: "按创建者用户 ID 过滤"}, + {Name: "created-from", Type: shortcut.FlagInt, Desc: "创建时间起始 (毫秒时间戳,含)"}, + {Name: "created-to", Type: shortcut.FlagInt, Desc: "创建时间截止 (毫秒时间戳,含)"}, + {Name: "modified-from", Type: shortcut.FlagInt, Desc: "修改时间起始 (毫秒时间戳,含)"}, + {Name: "modified-to", Type: shortcut.FlagInt, Desc: "修改时间截止 (毫秒时间戳,含)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量 (默认 10,最大 30)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,从上次返回的 nextCursor 获取"}, + }, + Tips: []string{ + `dws drive +search --query "季度汇报"`, + `dws drive +search --query "合同" --target file --extensions pdf,docx`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"keyword": rt.Str("query")} + if rt.Changed("target") { + params["searchTarget"] = rt.Str("target") + } + if rt.Changed("file-types") { + params["fileTypes"] = rt.StrSlice("file-types") + } + if rt.Changed("extensions") { + params["extensions"] = rt.StrSlice("extensions") + } + if rt.Changed("creator-uids") { + params["creatorUserIds"] = rt.StrSlice("creator-uids") + } + if rt.Changed("created-from") { + params["createdTimeFrom"] = rt.Int("created-from") + } + if rt.Changed("created-to") { + params["createdTimeTo"] = rt.Int("created-to") + } + if rt.Changed("modified-from") { + params["modifiedTimeFrom"] = rt.Int("modified-from") + } + if rt.Changed("modified-to") { + params["modifiedTimeTo"] = rt.Int("modified-to") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("drive", "search_files", params) + if err != nil { + return err + } + files := searchFilesProject(data) + return rt.Output(map[string]any{"count": len(files), "files": files}) + }, +} + +// searchFilesProject reshapes the raw search_files response into a clean, +// stable 钉盘 file list ({name,type,dentryId,fileSize,creatorId}) — the +// output-projection fidelity applied to every list/search shortcut. Both the +// list container and each field are probed defensively across candidate keys, +// so a missing container or unknown alias yields an empty list rather than a +// fabricated value. +func searchFilesProject(data map[string]any) []map[string]any { + raw := searchFilesContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + searchFilesPick(row, m, "name", "name", "fileName", "dentryName", "title") + searchFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType") + searchFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId") + searchFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length") + searchFilesPick(row, m, "creatorId", "creatorId", "creatorUserId", "creator", "creatorUid") + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// searchFilesContainer locates the file list array inside the response by +// trying the common container keys emitted across drive backends; the payload +// itself may also already wrap the array one level deeper. +func searchFilesContainer(data map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} { + if v, ok := data[k].([]any); ok { + return v + } + if inner, ok := data[k].(map[string]any); ok { + for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} { + if v, ok := inner[ik].([]any); ok { + return v + } + } + } + } + return nil +} + +// searchFilesPick copies the first matching alias from src into dst under the +// canonical key, leaving dst untouched when no alias is present. +func searchFilesPick(dst, src map[string]any, canonical string, aliases ...string) { + for _, a := range aliases { + if v, ok := src[a]; ok { + dst[canonical] = v + return + } + } +} + +// RecycleList → list_recycle_items +// RecycleRestore → restore_recycle_item +// PublishSet → set_file_publish (published=true) +// PublishUnset → set_file_publish (published=false) +// PublishStatus → get_file_publish_status +// ── 文档空间代理(doc MCP server)───────────────────────────── + +// ListDocs → list_nodes (doc) +// SearchDocs → search_documents (doc) +var SearchDocs = shortcut.Shortcut{ + Service: "drive", + Command: "+search-docs", + Product: "doc", + Description: "搜索文档空间文档", + Intent: "当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量"}, + }, + Tips: []string{`dws drive +search-docs --query "季度汇报"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"keyword": rt.Str("query")} + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + data, err := rt.CallMCPData("doc", "search_documents", params) + if err != nil { + return err + } + docs := searchDocsProject(data) + return rt.Output(map[string]any{"count": len(docs), "docs": docs}) + }, +} + +// searchDocsProject reshapes the raw search_documents response into a clean, +// stable document list ({name,nodeId,type,url}) — the output-projection +// fidelity applied to every list/search shortcut. Both the list container and +// each field are probed defensively across candidate keys, so a missing +// container or unknown alias yields an empty list rather than fabricated data. +func searchDocsProject(data map[string]any) []map[string]any { + raw := searchDocsContainer(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + searchDocsPick(row, m, "name", "name", "title", "docName", "nodeName", "fileName") + searchDocsPick(row, m, "nodeId", "nodeId", "id", "docId", "dentryUuid", "fileId") + searchDocsPick(row, m, "type", "type", "docType", "nodeType", "fileType") + searchDocsPick(row, m, "url", "url", "docUrl", "link", "webUrl") + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// searchDocsContainer locates the document list array inside the response by +// trying the common container keys; the payload may also wrap the array one +// level deeper under a common envelope. +func searchDocsContainer(data map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "documents", "docs", "nodes"} { + if v, ok := data[k].([]any); ok { + return v + } + if inner, ok := data[k].(map[string]any); ok { + for _, ik := range []string{"list", "items", "documents", "docs", "nodes", "result"} { + if v, ok := inner[ik].([]any); ok { + return v + } + } + } + } + return nil +} + +// searchDocsPick copies the first matching alias from src into dst under the +// canonical key, leaving dst untouched when no alias is present. +func searchDocsPick(dst, src map[string]any, canonical string, aliases ...string) { + for _, a := range aliases { + if v, ok := src[a]; ok { + dst[canonical] = v + return + } + } +} + +// Delete → delete_document (doc) +// Copy → copy_document (doc) +var Copy = shortcut.Shortcut{ + Service: "drive", + Command: "+copy", + Product: "doc", + Description: "复制文件/文档到指定位置", + Intent: "当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档/文件 ID", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + }, + Tips: []string{`dws drive +copy --node --folder `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + return rt.CallMCP("copy_document", params) + }, +} + +// Move → move_document (doc) +var Move = shortcut.Shortcut{ + Service: "drive", + Command: "+move", + Product: "doc", + Description: "移动文件/文档到指定位置", + Intent: "当你要把某个文件/文档从当前位置转移到另一个文件夹或知识库(整理归档、调整目录结构)时使用;输入源节点 node 及目标 folder/workspace,会实际改变文件所在位置,原位置不再保留该文件。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "文档/文件 ID", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + }, + Tips: []string{`dws drive +move --node --folder `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.Changed("workspace") { + params["workspaceId"] = rt.Str("workspace") + } + return rt.CallMCP("move_document", params) + }, +} + +// Rename → rename_document (doc) +// PermissionAdd → add_permission (doc) +// PermissionUpdate → update_permission (doc) +// PermissionList → list_permission (doc) +// PermissionRemove → remove_permission (doc) +// Recent → get_recent_list (doc) +var Recent = shortcut.Shortcut{ + Service: "drive", + Command: "+recent", + Product: "doc", + Description: "获取最近访问/编辑的文档列表", + Intent: "当你想快速找回「我最近看过/改过的那个文档」而不记得它放在哪时使用;可按操作类型(最近访问/最近编辑)和创建人(全部/我创建/他人创建)过滤,返回近期文档列表及其节点信息。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "operate-type", Type: shortcut.FlagInt, Desc: "操作类型: 0=最近访问(默认), 1=最近编辑"}, + {Name: "creator-type", Type: shortcut.FlagInt, Desc: "创建人过滤: 0=全部, 1=我创建, 2=他人创建"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 20,最大 20)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (从上次结果的 nextCursor 获取)"}, + }, + Tips: []string{ + `dws drive +recent`, + `dws drive +recent --operate-type 1 --creator-type 1`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("operate-type") { + params["operateTypes"] = []int{rt.Int("operate-type")} + } + if rt.Changed("creator-type") { + params["creatorType"] = rt.Int("creator-type") + } + if rt.Changed("limit") { + params["maxResults"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["nextToken"] = rt.Str("cursor") + } + // Project the verbose raw response (logId + per-item giant docUrl noise) + // down to a clean {count, items:[…], nextCursor, hasMore}. + data, err := rt.CallMCPData("doc", "get_recent_list", params) + if err != nil { + return err + } + return rt.Output(recentListProject(data)) + }, +} + +// recentListProject reshapes a get_recent_list response into a clean paginated +// document list, dropping transport noise (logId) while keeping the pagination +// cursor. +func recentListProject(data map[string]any) map[string]any { + items := []map[string]any{} + raw, _ := data["recentItems"].([]any) + for _, it := range raw { + m, ok := it.(map[string]any) + if !ok { + continue + } + items = append(items, map[string]any{ + "name": m["name"], + "nodeType": m["nodeType"], + "contentType": m["contentType"], + "accessTime": m["accessTime"], + "docUrl": m["docUrl"], + "nodeId": m["nodeId"], + }) + } + out := map[string]any{"count": len(items), "items": items} + if nc, ok := data["nextCursor"]; ok && nc != nil { + out["nextCursor"] = nc + } + if hm, ok := data["hasMore"]; ok { + out["hasMore"] = hm + } + return out +} + +func init() { + shortcut.Register( + List, + Info, + Download, + Search, + SearchDocs, + Copy, + Move, + Recent, + ) +} diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go new file mode 100644 index 00000000..adb3a2fa --- /dev/null +++ b/internal/shortcut/mail/mail.go @@ -0,0 +1,695 @@ +// 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 mail provides declarative shortcuts for the DingTalk mail (邮箱) service: +// mailbox / message / draft / thread / folder / tag / user / attachment / template / +// contact / auto-reply / rule operations. Each shortcut maps 1:1 onto an MCP tool +// declared in internal/helpers/mail.go. +package mail + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ── mailbox ──────────────────────────────────────────────── + +// MailboxList 查询当前用户可用的邮箱地址。 +// ── message ──────────────────────────────────────────────── + +// Search 使用 KQL 查询表达式搜索邮件。 +// Messages 列出指定文件夹中的邮件(底层按 folderId 查询)。 +// Message 根据邮件 ID 获取邮件完整内容(含正文)。 +// MessageVerify 根据 internetMessageId 查询邮件发送状态。 +// Send 发送一封邮件到指定收件人。 +// MessageMove 将多封邮件批量移动到目标文件夹。 +// MessageDelete 批量删除指定邮件(移入已删除文件夹或永久删除)。 +// MessageModify 批量修改邮件状态(标记已读/未读/添加标签/移除标签)。 +// ── draft ────────────────────────────────────────────────── + +// DraftCreate 创建一封邮件草稿并保存到草稿箱。 +// DraftEdit 更新草稿箱中已有草稿的内容。 +// DraftSend 将草稿箱中已有的草稿发送出去。 +// ── thread ───────────────────────────────────────────────── + +// ThreadList 列出指定邮箱文件夹下的邮件会话。 +var ThreadList = shortcut.Shortcut{ + Service: "mail", + Command: "+thread-list", + Product: "mail", + Description: "列出指定邮箱文件夹下的邮件会话(thread)", + Intent: "当你想按会话(同一往来主题的邮件串)而非单封邮件来浏览某个文件夹时使用;传入邮箱和文件夹 ID,可按时间范围和升降序筛选,返回会话列表及其 conversationId,供 +thread 查看详情。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "会话所属邮箱地址", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "邮件文件夹 ID(不是文件夹名称)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "本次列出的会话数,最大 100"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,首次请求可不传"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始 UTC 时间,如 2024-01-01T00:00:00Z"}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"}, + {Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"}, + }, + Tips: []string{ + `dws mail +thread-list --email user@company.com --folder 104 --limit 20`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "email": rt.Str("email"), + "folderId": rt.Str("folder"), + "size": rt.Int("limit"), + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + if rt.Changed("start") { + params["startTime"] = rt.Str("start") + } + if rt.Changed("end") { + params["endTime"] = rt.Str("end") + } + if rt.Changed("ascending") { + params["isAscending"] = rt.Bool("ascending") + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", params) + if err != nil { + return err + } + threads := threadListProject(data) + return rt.Output(map[string]any{"count": len(threads), "threads": threads}) + }, +} + +// threadListProject reshapes the raw list_mailbox_threads response into a clean +// {conversationId, subject, lastUpdated, isRead} thread list — +// clean output projection. Both the list container and per-item +// field names are probed defensively across candidate keys, so an empty/unknown +// shape yields an empty list rather than a crash or fabricated data. +func threadListProject(data map[string]any) []map[string]any { + raw := threadListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok { + row["conversationId"] = v + } + if v, ok := threadListFirst(m, "subject", "title", "topic"); ok { + row["subject"] = v + } + if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "updateTime", "modifiedTime", "sentDateTime"); ok { + row["lastUpdated"] = v + } + if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok { + row["isRead"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// threadListResolveList locates the list payload inside the response, tolerating +// a bare top-level array container or nesting one level deeper. +func threadListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// threadListFirst returns the first present candidate key's value. +func threadListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// Thread 根据会话 ID 获取会话详情。 +// ThreadUpdate 修改单个邮件会话的状态或标签。 +// ThreadBatchUpdate 批量修改邮件会话的状态或标签。 +// ThreadTrash 删除指定邮件会话(移入已删除文件夹,不可撤销)。 +// ThreadBatchTrash 批量删除指定邮件会话(不可撤销)。 +// ── folder ───────────────────────────────────────────────── + +// FolderList 列举邮件文件夹。 +var FolderList = shortcut.Shortcut{ + Service: "mail", + Command: "+folder-list", + Product: "mail", + Description: "列出顶层文件夹或指定父文件夹下的子文件夹", + Intent: "当你需要了解某个邮箱有哪些文件夹、或要取得文件夹 ID 以便移动邮件、按文件夹列信/建规则时使用;传入邮箱(可选父文件夹 ID 查子级),返回文件夹列表及其 ID。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"}, + }, + Tips: []string{ + `dws mail +folder-list --email user@company.com`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "email": rt.Str("email"), + } + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + data, err := rt.CallMCPData("mail", "list_folders", params) + if err != nil { + return err + } + folders := folderListProject(data) + return rt.Output(map[string]any{"count": len(folders), "folders": folders}) + }, +} + +// folderListProject reshapes the raw list_folders response into a clean +// {id, name, parentId} folder list — clean output projection. Both +// the list container and per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func folderListProject(data map[string]any) []map[string]any { + raw := folderListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok { + row["id"] = v + } + if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok { + row["name"] = v + } + if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok { + row["parentId"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// folderListResolveList locates the list payload inside the response, tolerating +// a bare top-level array container or nesting one level deeper. +func folderListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "folders"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "folders", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// folderListFirst returns the first present candidate key's value. +func folderListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// FolderCreate 创建邮件文件夹。 +// FolderUpdate 更新邮件文件夹名称。 +// FolderDelete 删除邮件文件夹。 +// ── tag ──────────────────────────────────────────────────── + +// TagList 列举邮件标签。 +var TagList = shortcut.Shortcut{ + Service: "mail", + Command: "+tag-list", + Product: "mail", + Description: "列出指定邮箱下的所有邮件标签", + Intent: "当你要查看邮箱里有哪些标签、或需要取得标签 ID 以便给邮件/会话加标签时使用;传入邮箱地址,返回全部邮件标签及其 ID。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "用户的邮箱地址", Required: true}, + }, + Tips: []string{ + `dws mail +tag-list --email user@company.com`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("mail", "list_tags", map[string]any{ + "email": rt.Str("email"), + }) + if err != nil { + return err + } + tags := tagListProject(data) + return rt.Output(map[string]any{"count": len(tags), "tags": tags}) + }, +} + +// tagListProject reshapes the raw list_tags response into a clean +// {id, name, parentId} tag list — clean output projection. Both the +// list container and per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func tagListProject(data map[string]any) []map[string]any { + raw := tagListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok { + row["id"] = v + } + if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok { + row["name"] = v + } + if v, ok := tagListFirst(m, "parentId", "parent_id"); ok { + row["parentId"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// tagListResolveList locates the list payload inside the response, tolerating a +// bare top-level array container or nesting one level deeper. +func tagListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "tags"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "tags", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// tagListFirst returns the first present candidate key's value. +func tagListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// TagCreate 创建邮件标签。 +// TagUpdate 更新邮件标签名称。 +// TagDelete 删除邮件标签。 +// ── user ─────────────────────────────────────────────────── + +// UserSearch 按关键词或工号搜索邮箱用户。 +var UserSearch = shortcut.Shortcut{ + Service: "mail", + Command: "+user-search", + Product: "mail", + Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", + Intent: "当你只知道同事的姓名或工号、需要查出其企业邮箱地址以便发信或添加联系人时使用;提供关键词或工号(至少其一),返回匹配的企业邮箱用户列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"}, + {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"}, + {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, + }, + Tips: []string{ + `dws mail +user-search --keyword "张三"`, + `dws mail +user-search --email user@company.com --employee-no "E123456"`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("keyword") == "" && rt.Str("employee-no") == "" { + return fmt.Errorf("--keyword 与 --employee-no 至少需要提供一个") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Str("keyword") != "" { + params["keyword"] = rt.Str("keyword") + } + if rt.Str("employee-no") != "" { + params["employeeNo"] = rt.Str("employee-no") + } + if rt.Changed("email") { + params["email"] = rt.Str("email") + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + if rt.Changed("limit") { + params["size"] = rt.Str("limit") + } + data, err := rt.CallMCPData("mail", "search_mail_users", params) + if err != nil { + return err + } + users := userSearchProject(data) + return rt.Output(map[string]any{"count": len(users), "users": users}) + }, +} + +// userSearchProject reshapes the raw search_mail_users response into a clean +// {name, email, employeeNo, userId} user list — clean output projection. +// Both the list container and per-item field names are probed defensively +// across candidate keys, so an empty/unknown shape yields an empty list rather +// than a crash or fabricated data. +func userSearchProject(data map[string]any) []map[string]any { + raw := userSearchResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok { + row["name"] = v + } + if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok { + row["email"] = v + } + if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok { + row["employeeNo"] = v + } + if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok { + row["userId"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// userSearchResolveList locates the list payload inside the response, tolerating +// a bare top-level array container or nesting one level deeper. +func userSearchResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "users"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "users", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// userSearchFirst returns the first present candidate key's value. +func userSearchFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ── attachment ───────────────────────────────────────────── + +// AttachmentList 列举邮件附件。 +// ── template ─────────────────────────────────────────────── + +// TemplateCreate 创建邮件模板。 +// TemplateList 列举邮件模板。 +var TemplateList = shortcut.Shortcut{ + Service: "mail", + Command: "+template-list", + Product: "mail", + Description: "列出指定邮箱的所有邮件模板", + Intent: "当你想查看某个邮箱下已有哪些邮件模板、或需要取得模板 ID 以便查看详情或更新时使用;传入邮箱和每页数量,返回模板列表,支持分页。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, + }, + Tips: []string{ + `dws mail +template-list --email user@company.com --limit 20`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "email": rt.Str("email"), + "size": rt.Str("limit"), + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_user_message_templates", params) + if err != nil { + return err + } + templates := templateListProject(data) + return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + }, +} + +// templateListProject reshapes the raw list_user_message_templates response into +// a clean {id, name, subject} template list — clean output projection. +// Both the list container and per-item field names are probed defensively +// across candidate keys, so an empty/unknown shape yields an empty list rather +// than a crash or fabricated data. +func templateListProject(data map[string]any) []map[string]any { + raw := templateListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok { + row["id"] = v + } + if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok { + row["name"] = v + } + if v, ok := templateListFirst(m, "subject", "title"); ok { + row["subject"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// templateListResolveList locates the list payload inside the response, +// tolerating a bare top-level array container or nesting one level deeper. +func templateListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "templates"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "templates", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// templateListFirst returns the first present candidate key's value. +func templateListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// TemplateGet 获取邮件模板详情。 +// TemplateUpdate 更新邮件模板。 +// TemplateDelete 删除邮件模板。 +// ── contact ──────────────────────────────────────────────── + +// ContactCreate 创建邮件联系人。 +// ContactList 列举邮件联系人。 +var ContactList = shortcut.Shortcut{ + Service: "mail", + Command: "+contact-list", + Product: "mail", + Description: "列出指定邮箱的所有邮件联系人", + Intent: "当你想查看某邮箱通讯录里有哪些联系人、或需要取得联系人 ID 以便更新或删除时使用;传入邮箱和每页数量,返回联系人列表,支持分页。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, + }, + Tips: []string{ + `dws mail +contact-list --email user@company.com --limit 20`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "email": rt.Str("email"), + "size": rt.Str("limit"), + } + if rt.Changed("cursor") { + params["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_user_mail_contacts", params) + if err != nil { + return err + } + contacts := contactListProject(data) + return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts}) + }, +} + +// contactListProject reshapes the raw list_user_mail_contacts response into a +// clean {id, contactEmail, displayName} contact list — output-projection +// clean output projection. Both the list container and per-item field names are probed +// defensively across candidate keys, so an empty/unknown shape yields an empty +// list rather than a crash or fabricated data. +func contactListProject(data map[string]any) []map[string]any { + raw := contactListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok { + row["id"] = v + } + if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok { + row["contactEmail"] = v + } + if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok { + row["displayName"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// contactListResolveList locates the list payload inside the response, +// tolerating a bare top-level array container or nesting one level deeper. +func contactListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "contacts"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "contacts", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// contactListFirst returns the first present candidate key's value. +func contactListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ContactUpdate 更新邮件联系人。 +// ContactBatchDelete 批量删除邮件联系人。 +// ── auto-reply ───────────────────────────────────────────── + +// AutoReplyGet 获取用户的自动回复配置。 +// ── rule ─────────────────────────────────────────────────── + +// RuleList 列出个人收信规则。 +// RuleCreate 创建个人收信规则。 +// RuleUpdate 更新个人收信规则。 +// RuleDelete 删除个人收信规则。 +// RuleAdjust 调整收信规则排序。 +func init() { + shortcut.Register( + ThreadList, + FolderList, + TagList, + UserSearch, + TemplateList, + ContactList, + ) +} diff --git a/internal/shortcut/minutes/minutes.go b/internal/shortcut/minutes/minutes.go new file mode 100644 index 00000000..26cc621b --- /dev/null +++ b/internal/shortcut/minutes/minutes.go @@ -0,0 +1,290 @@ +// 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 minutes contains declarative shortcuts for DingTalk AI 听记 (minutes). +// Tool names and params mirror internal/helpers/minutes.go verbatim. +package minutes + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + +// listeningNoteCmdTool is the gateway-registered name of the 听记指令 tool. +// It is registered under a Chinese title; the legacy English name returns +// "PARAM_ERROR - 未找到指定工具". Copied verbatim from the helper. +const listeningNoteCmdTool = "执行听记指令-发起AI听记录音" + +// ── list ──────────────────────────────────────────────────────────────────── + +var ListMine = shortcut.Shortcut{ + Service: "minutes", + Command: "+list-mine", + Product: "minutes", + Description: "查询我创建的听记列表", + Intent: "当你想找回自己发起或录制的某次听记(会议纪要),却只记得大概的标题关键字时使用;可按关键字筛选并分页,返回自己创建的听记列表及其 taskUuid,便于后续查看摘要、转写或待办。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数据条数"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页 token (首页留空)"}, + }, + Tips: []string{`dws minutes +list-mine --query "周会" --limit 10`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return callList(rt, "created") + }, +} + +var ListShared = shortcut.Shortcut{ + Service: "minutes", + Command: "+list-shared", + Product: "minutes", + Description: "查询他人共享给我的听记列表", + Intent: "当你要找同事分享给你的会议听记、想快速定位别人共享过来的纪要时使用;可按关键字筛选并分页,返回他人共享给你的听记列表及 taskUuid。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数据条数"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页 token (首页留空)"}, + }, + Tips: []string{`dws minutes +list-shared --limit 20`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return callList(rt, "shared") + }, +} + +var ListAll = shortcut.Shortcut{ + Service: "minutes", + Command: "+list-all", + Product: "minutes", + Description: "查询我有权限访问的所有听记列表", + Intent: "当你不确定某条听记是自己创建还是别人共享、想在所有可访问的听记中一次性检索时使用;合并「我创建的」和「共享给我的」,按关键字筛选并分页返回全部有权限的听记及 taskUuid。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数据条数"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页 token (首页留空)"}, + }, + Tips: []string{`dws minutes +list-all --query "周会" --limit 20`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return callList(rt, "noLimit") + }, +} + +// callList wraps list_by_keyword_and_time_range for mine/shared/all. +func callList(rt *shortcut.RuntimeContext, belonging string) error { + params := map[string]any{"belongingConditionId": belonging} + if rt.Changed("limit") { + params["maxResults"] = rt.Int("limit") + } + if rt.Changed("query") { + params["keyword"] = rt.Str("query") + } + if rt.Changed("cursor") { + params["nextToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", params) + if err != nil { + return err + } + minutes := callListProject(data) + return rt.Output(map[string]any{"count": len(minutes), "minutes": minutes}) +} + +// callListProject reshapes the raw list_by_keyword_and_time_range response into a +// clean listening-note list (taskUuid/title/creator/startTime/endTime/url/status) +// — the clean output projection applied to every list command. +// The list container and field names are probed defensively across candidate +// keys so the projection tolerates response-shape drift; unknown keys are never +// invented. +func callListProject(data map[string]any) []map[string]any { + raw := callListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok { + row["taskUuid"] = v + } + if v, ok := callListFirst(m, "title", "name"); ok { + row["title"] = v + } + if v, ok := callListFirst(m, "creator", "creatorName", "createUserName", "creatorNick"); ok { + row["creator"] = v + } + if v, ok := callListFirst(m, "startTime", "gmtStart", "beginTime"); ok { + row["startTime"] = v + } + if v, ok := callListFirst(m, "endTime", "gmtEnd", "deadline"); ok { + row["endTime"] = v + } + if v, ok := callListFirst(m, "url", "shareUrl", "link"); ok { + row["url"] = v + } + if v, ok := callListFirst(m, "status", "taskStatus", "state"); ok { + row["status"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// callListResolveList locates the list payload inside the response, tolerating a +// bare top-level array or nesting under result/data/list/items/records containers. +func callListResolveList(data map[string]any) []any { + for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + // container may itself wrap the list one level deeper + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// callListFirst returns the first present candidate key's value. +func callListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// ── get ───────────────────────────────────────────────────────────────────── + +// ── update ────────────────────────────────────────────────────────────────── + +// ── record ────────────────────────────────────────────────────────────────── + +var RecordStart = shortcut.Shortcut{ + Service: "minutes", + Command: "+record-start", + Product: "minutes", + Description: "发起听记(开始录音)", + Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"}, + }, + Tips: []string{`dws minutes +record-start --session-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"cmd": "create"} + if rt.Changed("session-id") { + params["sessionId"] = rt.Str("session-id") + } + return rt.CallMCP(listeningNoteCmdTool, params) + }, +} + +var RecordPause = shortcut.Shortcut{ + Service: "minutes", + Command: "+record-pause", + Product: "minutes", + Description: "暂停听记录音", + Intent: "录音进行中想临时中断(如中场休息、切换话题)又不想结束整条听记时使用;传入正在录音的听记 taskUuid,会真实暂停该次录音,之后可用 +record-resume 继续。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true}, + {Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"}, + }, + Tips: []string{`dws minutes +record-pause --id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"cmd": "pause", "uuid": rt.Str("id")} + if rt.Changed("session-id") { + params["sessionId"] = rt.Str("session-id") + } + return rt.CallMCP(listeningNoteCmdTool, params) + }, +} + +var RecordResume = shortcut.Shortcut{ + Service: "minutes", + Command: "+record-resume", + Product: "minutes", + Description: "恢复听记录音", + Intent: "之前用 +record-pause 暂停过的听记,现在想接着录时使用;传入该听记 taskUuid,会真实恢复录音,继续追加到同一条听记中。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true}, + {Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"}, + }, + Tips: []string{`dws minutes +record-resume --id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"cmd": "resume", "uuid": rt.Str("id")} + if rt.Changed("session-id") { + params["sessionId"] = rt.Str("session-id") + } + return rt.CallMCP(listeningNoteCmdTool, params) + }, +} + +var RecordStop = shortcut.Shortcut{ + Service: "minutes", + Command: "+record-stop", + Product: "minutes", + Description: "结束听记录音", + Intent: "会议开完、想彻底停止录音并让系统开始生成转写与 AI 纪要时使用;传入正在录音的听记 taskUuid,会真实结束该次录音,结束后无法再恢复到这条听记继续录。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true}, + {Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"}, + }, + Tips: []string{`dws minutes +record-stop --id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"cmd": "end", "uuid": rt.Str("id")} + if rt.Changed("session-id") { + params["sessionId"] = rt.Str("session-id") + } + return rt.CallMCP(listeningNoteCmdTool, params) + }, +} + +// ── mind-graph ────────────────────────────────────────────────────────────── + +// ── speaker ───────────────────────────────────────────────────────────────── + +// ── hot-word ──────────────────────────────────────────────────────────────── + +// ── replace-text ──────────────────────────────────────────────────────────── + +// ── upload ────────────────────────────────────────────────────────────────── + +// ── permission ────────────────────────────────────────────────────────────── + +// ── tag ───────────────────────────────────────────────────────────────────── + +func init() { + shortcut.Register( + ListMine, + ListShared, + ListAll, + RecordStart, + RecordPause, + RecordResume, + RecordStop, + ) +} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go new file mode 100644 index 00000000..bcb21dd7 --- /dev/null +++ b/internal/shortcut/oa/oa.go @@ -0,0 +1,444 @@ +// 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 oa registers declarative shortcuts for the DingTalk OA approval +// service, wrapping the raw MCP tools exposed by the dws oa helper. +package oa + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ListPending — 查询待我处理的审批 (list_pending_approvals) +var ListPending = shortcut.Shortcut{ + Service: "oa", + Command: "+list-pending", + Product: "oa", + Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", + Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true}, + {Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true}, + {Name: "page", Type: shortcut.FlagString, Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, + }, + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "starTime": rt.Int("start"), + "endTime": rt.Int("end"), + } + if rt.Changed("page") { + params["pageNum"] = rt.Str("page") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Str("limit") + } + if rt.Changed("query") { + params["query"] = rt.Str("query") + } + data, err := rt.CallMCPData("oa", "list_pending_approvals", params) + if err != nil { + return err + } + instances := listPendingProject(data) + return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + }, +} + +// listPendingProject reshapes the raw list_pending_approvals response into the +// same clean {processInstanceId, title, status, createTime} approval list as +// +list-executed/+list-submitted, so all approval-instance listings project +// identically. It reuses the shared oaInstance* defensive probes, tolerating +// response-shape and key-spelling drift. +func listPendingProject(data map[string]any) []map[string]any { + raw := oaInstanceResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaInstanceProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// Detail — 获取审批实例详情 (get_processInstance_detail) +// Approve — 同意审批 (approve_processInstance) +// Reject — 拒绝审批 (reject_processInstance) +// Revoke — 撤销已发起的审批 (revoke_processInstance) +// Records — 获取审批操作记录 (get_processInstance_records) +// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances) +// ListTasks — 查询待我审批的任务 ID (list_pending_tasks) +// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process) +var ListForms = shortcut.Shortcut{ + Service: "oa", + Command: "+list-forms", + Product: "oa", + Description: "获取当前用户可见的审批表单列表", + Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, + }, + Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{ + "cursor": rt.Int("cursor"), + "pageSize": rt.Int("limit"), + }) + if err != nil { + return err + } + forms := listFormsProject(data) + return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + }, +} + +// listFormsProject reshapes the raw list_user_visible_process response into a +// clean approval-form list ({processCode, name, iconUrl}) — the output-projection +// fidelity the framework applies to every list command. The list container and +// per-item field names are probed defensively across candidate keys so the +// projection tolerates response-shape drift rather than crashing or fabricating. +func listFormsProject(data map[string]any) []map[string]any { + raw := oaFormResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaFormProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// oaFormResolveList locates the form list inside a response, tolerating a bare +// top-level array or nesting one level under a common envelope key. +func oaFormResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// oaFormProjectItem picks the stable identity/label fields of a single approval +// form, probing candidate key spellings so it survives snake/camel drift. +func oaFormProjectItem(m map[string]any) map[string]any { + row := map[string]any{} + if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok { + row["processCode"] = v + } + if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok { + row["name"] = v + } + if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok { + row["iconUrl"] = v + } + return row +} + +// oaFormFirst returns the first present candidate key's value. +func oaFormFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// SearchForms — 按关键字模糊搜索可见审批表单 (search_form) +var SearchForms = shortcut.Shortcut{ + Service: "oa", + Command: "+search-forms", + Product: "oa", + Description: "按关键字模糊搜索当前用户可见的审批表单", + Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}, + }, + Tips: []string{`dws oa +search-forms --query 报销`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("oa", "search_form", map[string]any{ + "query": rt.Str("query"), + }) + if err != nil { + return err + } + forms := searchFormsProject(data) + return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + }, +} + +// searchFormsProject reshapes the raw search_form response into the same clean +// {processCode, name, iconUrl} list as +list-forms, so both approval-form +// listings project identically. It reuses the shared oaForm* defensive probes, +// tolerating response-shape and key-spelling drift. +func searchFormsProject(data map[string]any) []map[string]any { + raw := oaFormResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaFormProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user) +// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks) +var ListExecuted = shortcut.Shortcut{ + Service: "oa", + Command: "+list-executed", + Product: "oa", + Description: "获取当前用户已经处理过的审批单列表", + Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, + }, + Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "pageNumber": rt.Str("page"), + "pageSize": rt.Str("limit"), + } + if rt.Changed("query") { + params["query"] = rt.Str("query") + } + data, err := rt.CallMCPData("oa", "get_done_tasks", params) + if err != nil { + return err + } + instances := listExecutedProject(data) + return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + }, +} + +// listExecutedProject reshapes the raw get_done_tasks response into a clean +// approval-instance list ({processInstanceId, title, status, createTime}) — the +// the clean output projection applied to every list command. The +// list container and per-item field names are probed defensively across +// candidate keys so the projection tolerates response-shape drift rather than +// crashing or fabricating data. +func listExecutedProject(data map[string]any) []map[string]any { + raw := oaInstanceResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaInstanceProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// oaInstanceResolveList locates the approval-instance list inside a response, +// tolerating a bare top-level array or nesting one level under a common +// envelope key. +func oaInstanceResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// oaInstanceProjectItem picks the stable identity/label fields of a single +// approval instance, probing candidate key spellings so it survives +// snake/camel drift. +func oaInstanceProjectItem(m map[string]any) map[string]any { + row := map[string]any{} + if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok { + row["processInstanceId"] = v + } + if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok { + row["title"] = v + } + if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok { + row["status"] = v + } + if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok { + row["createTime"] = v + } + return row +} + +// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances) +var ListSubmitted = shortcut.Shortcut{ + Service: "oa", + Command: "+list-submitted", + Product: "oa", + Description: "获取当前用户已发起的审批单列表", + Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, + }, + Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "pageNumber": rt.Str("page"), + "pageSize": rt.Str("limit"), + } + if rt.Changed("query") { + params["query"] = rt.Str("query") + } + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + instances := listSubmittedProject(data) + return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + }, +} + +// listSubmittedProject reshapes the raw get_submitted_instances response into +// the same clean {processInstanceId, title, status, createTime} approval list +// as +list-executed, so both instance listings project identically. It reuses +// the shared oaInstance* defensive probes, tolerating response-shape and +// key-spelling drift. +func listSubmittedProject(data map[string]any) []map[string]any { + raw := oaInstanceResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaInstanceProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances) +var ListCc = shortcut.Shortcut{ + Service: "oa", + Command: "+list-cc", + Product: "oa", + Description: "获取抄送当前用户的审批单列表", + Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, + }, + Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "pageNumber": rt.Str("page"), + "pageSize": rt.Str("limit"), + } + if rt.Changed("query") { + params["query"] = rt.Str("query") + } + data, err := rt.CallMCPData("oa", "get_noticed_instances", params) + if err != nil { + return err + } + instances := listCcProject(data) + return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + }, +} + +// listCcProject reshapes the raw get_noticed_instances response into the same +// clean {processInstanceId, title, status, createTime} approval list as the +// other instance listings, so all approval-instance listings project +// identically. It reuses the shared oaInstance* defensive probes, tolerating +// response-shape and key-spelling drift. +func listCcProject(data map[string]any) []map[string]any { + raw := oaInstanceResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if row := oaInstanceProjectItem(m); len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// RedirectTask — 转交审批任务给其他人 (redirect_task) +// Comment — 对审批实例添加评论 (dingflow_comments) +// CcNotice — 对审批实例进行抄送 (oa_cc_noticer) +// AppendTask — 对审批任务进行加签 (append_task) +// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities) +// RevertTask — 退回审批任务到指定节点 (revert_task) +func init() { + shortcut.Register( + ListPending, + ListForms, + SearchForms, + ListExecuted, + ListSubmitted, + ListCc, + ) +} diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go new file mode 100644 index 00000000..5c486714 --- /dev/null +++ b/internal/shortcut/report/report.go @@ -0,0 +1,237 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package report declares declarative shortcuts for the DingTalk +// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a +// single MCP tool call; tool names and parameter keys mirror the DWS report +// helper (internal/helpers/report.go) verbatim. +package report + +import ( + "fmt" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00) +// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior. +func reportParseISOMillis(name, s string) (int64, error) { + s = strings.TrimSpace(s) + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err) + } + return t.UnixMilli(), nil +} + +// TemplateList lists the report templates available to the current user. +// TemplateGet reads a single report template's field definitions by name. +// InboxList lists reports received by the current user within a time window. +var InboxList = shortcut.Shortcut{ + Service: "report", + Command: "+inbox-list", + Product: "report", + Description: "列出我收到的日报(按时间范围分页)", + Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, + {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"}, + }, + Tips: []string{ + `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + startMs, err := reportParseISOMillis("start", rt.Str("start")) + if err != nil { + return err + } + endMs, err := reportParseISOMillis("end", rt.Str("end")) + if err != nil { + return err + } + params := map[string]any{ + "startTime": startMs, + "endTime": endMs, + "cursor": rt.Int("cursor"), + "size": rt.Int("size"), + } + if rt.Changed("sender-user-ids") { + params["senderUserIds"] = rt.StrSlice("sender-user-ids") + } + data, err := rt.CallMCPData("report", "get_received_report_list", params) + if err != nil { + return err + } + reports := reportEntryListProject(data) + return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + }, +} + +// reportEntryListProject reshapes the raw report-list responses +// (get_received_report_list / get_send_report_list) into a clean, stable list +// of report summaries — the output-projection fidelity the framework applies to +// every list command. Both the list container and the per-item field names are +// probed defensively across candidate keys so the projection tolerates +// response-shape drift and never fabricates data: an empty/unknown shape yields +// an empty list rather than a crash. +func reportEntryListProject(data map[string]any) []map[string]any { + raw := reportEntryListResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok { + row["reportId"] = v + } + if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok { + row["templateName"] = v + } + if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok { + row["creatorName"] = v + } + if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok { + row["creatorUserId"] = v + } + if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok { + row["createTime"] = v + } + if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok { + row["modifiedTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// reportEntryListResolveList locates the list payload inside the response, +// tolerating a bare top-level array or nesting one level under a common +// envelope key. +func reportEntryListResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// reportEntryListFirst returns the first present candidate key's value. +func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// OutboxList lists reports sent/created by the current user. +var OutboxList = shortcut.Shortcut{ + Service: "report", + Command: "+outbox-list", + Product: "report", + Description: "列出我发出的日报(可选时间/模版名过滤)", + Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"}, + {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"}, + {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"}, + {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"}, + }, + Tips: []string{ + `dws report +outbox-list --cursor 0 --size 20`, + `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "cursor": rt.Int("cursor"), + "size": rt.Int("size"), + } + if rt.Changed("start") { + ms, err := reportParseISOMillis("start", rt.Str("start")) + if err != nil { + return err + } + params["startTime"] = ms + } + if rt.Changed("end") { + ms, err := reportParseISOMillis("end", rt.Str("end")) + if err != nil { + return err + } + params["endTime"] = ms + } + if rt.Changed("modified-start") { + ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start")) + if err != nil { + return err + } + params["modifiedStartTime"] = ms + } + if rt.Changed("modified-end") { + ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end")) + if err != nil { + return err + } + params["modifiedEndTime"] = ms + } + if rt.Changed("template-name") { + params["report_template_name"] = rt.Str("template-name") + } + data, err := rt.CallMCPData("report", "get_send_report_list", params) + if err != nil { + return err + } + reports := reportEntryListProject(data) + return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + }, +} + +// EntryGet reads the full body of a single report by id. +// EntryStats reads the read-receipt statistics of a single report. +// EntrySubmit submits a new report against a template. +func init() { + shortcut.Register( + InboxList, + OutboxList, + ) +} diff --git a/internal/shortcut/sheet/sheet.go b/internal/shortcut/sheet/sheet.go new file mode 100644 index 00000000..4715b6fb --- /dev/null +++ b/internal/shortcut/sheet/sheet.go @@ -0,0 +1,193 @@ +// 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 sheet declares high-fidelity shortcuts for the DingTalk sheet MCP +// product. Tool names and parameter keys mirror the helper commands under +// internal/helpers/sheet_*.go verbatim. +package sheet + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ── workbook & worksheet ────────────────────────────────────────────── + +// Create creates a new DingTalk online spreadsheet document. +// ListSheets lists all worksheets in a spreadsheet document. +var ListSheets = shortcut.Shortcut{ + Service: "sheet", + Command: "+list-sheets", + Product: "sheet", + Description: "获取表格文档中全部工作表列表", + Intent: "当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, + }, + Tips: []string{`dws sheet +list-sheets --node NODE_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("sheet", "get_all_sheets", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + sheets := listSheetsProject(data) + return rt.Output(map[string]any{"count": len(sheets), "sheets": sheets}) + }, +} + +// listSheetsProject reshapes the raw get_all_sheets response into a clean +// worksheet list (sheetId/title/index/visibility/rowCount/columnCount) — the +// the clean output projection applied to every list command. Both +// the list container and the per-item field names are probed defensively across +// candidate keys, so an empty/unknown shape yields an empty list rather than a +// crash or fabricated data. +func listSheetsProject(data map[string]any) []map[string]any { + raw := listSheetsResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v, ok := listSheetsFirst(m, "sheetId", "sheet_id", "id"); ok { + row["sheetId"] = v + } + if v, ok := listSheetsFirst(m, "title", "name", "sheetName", "sheet_name"); ok { + row["title"] = v + } + if v, ok := listSheetsFirst(m, "index", "sheetIndex"); ok { + row["index"] = v + } + if v, ok := listSheetsFirst(m, "visibility", "hidden", "visible"); ok { + row["visibility"] = v + } + if v, ok := listSheetsFirst(m, "rowCount", "row_count", "rows"); ok { + row["rowCount"] = v + } + if v, ok := listSheetsFirst(m, "columnCount", "column_count", "columns", "colCount"); ok { + row["columnCount"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listSheetsResolveList locates the worksheet array inside the response, +// tolerating a bare top-level array container or nesting one level deeper under +// a common envelope key. +func listSheetsResolveList(data map[string]any) []any { + if data == nil { + return []any{} + } + for _, key := range []string{"sheets", "result", "data", "list", "items", "worksheets"} { + v, ok := data[key] + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr + } + if inner, ok := v.(map[string]any); ok { + for _, ik := range []string{"sheets", "list", "items", "worksheets", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return []any{} +} + +// listSheetsFirst returns the first present candidate key's value. +func listSheetsFirst(m map[string]any, keys ...string) (any, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + return v, true + } + } + return nil, false +} + +// SheetInfo returns the detail of a single worksheet. +// SheetCreate adds a new worksheet to a spreadsheet. +// SheetUpdate updates worksheet properties (name/position/hidden/freeze/tab color). +// SheetCopy duplicates a worksheet within the same spreadsheet. +// SheetDelete removes a worksheet (irreversible). +// ── data read/write ─────────────────────────────────────────────────── + +// Read reads structured per-cell data from a worksheet range. +var Read = shortcut.Shortcut{ + Service: "sheet", + Command: "+read", + Product: "sheet", + Description: "读取工作表指定范围的结构化单元格数据", + Intent: "当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, + {Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表)"}, + {Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据)"}, + {Name: "value-render-option", Type: shortcut.FlagString, Desc: "取值模式", Enum: []string{"formatted_value", "raw_value", "formula"}}, + }, + Tips: []string{`dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range "A1:D10"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"nodeId": rt.Str("node")} + if rt.Changed("sheet-id") { + params["sheetId"] = rt.Str("sheet-id") + } + if rt.Changed("range") { + params["range"] = rt.Str("range") + } + if rt.Changed("value-render-option") { + params["valueRenderOption"] = rt.Str("value-render-option") + } + return rt.CallMCP("get_cell_infos", params) + }, +} + +// Write updates a worksheet range with a 2D JSON array of cell objects. +// Append appends rows to the end of a worksheet. +// CSVGet reads worksheet data as RFC 4180 CSV. +// CSVPut writes CSV text into a worksheet at a start cell (values only). +// CellsSearch finds cells matching a text query. +// CellsReplace finds and replaces text across a worksheet. +// CellsClear clears content/format of a worksheet range. +// ── range operations ────────────────────────────────────────────────── + +// RangeSort sorts a worksheet range by one or more keys. +// RangeFill auto-fills a target range from a source range. +// RangeCopy copies a source range to a target location (supports cross-sheet). +// RangeMove moves a source range to a target location (source is cleared). +// ── dimensions & merge ──────────────────────────────────────────────── + +// InsertDimension inserts empty rows or columns before a position. +// AddDimension appends empty rows or columns at the end of a worksheet. +// MoveDimension moves rows or columns to a destination position. +// UpdateDimension updates hidden state and/or row height / column width. +// DeleteDimension deletes rows or columns from a position (irreversible). +// MergeCells merges a range of cells. +// UnmergeCells unmerges cells within a range. +// ── dropdown lists ──────────────────────────────────────────────────── + +// SetDropdown sets a dropdown list on a cell range. +// GetDropdown reads dropdown list configuration for a range. +// DeleteDropdown removes dropdown list configuration from a range. +func init() { + shortcut.Register( + ListSheets, + Read, + ) +} diff --git a/internal/shortcut/todo/todo.go b/internal/shortcut/todo/todo.go new file mode 100644 index 00000000..0659eee6 --- /dev/null +++ b/internal/shortcut/todo/todo.go @@ -0,0 +1,429 @@ +// 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 todo declares high-fidelity shortcuts for the DingTalk "todo" +// (personal todo) MCP service. Tool names and parameter keys are copied verbatim +// from internal/helpers/todo.go; do not invent tools or params here. +package todo + +import ( + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// Create maps helper `create_personal_todo`. +// CreateSub maps helper `create_personal_sub_todo`. +// GetMyTasks maps helper `get_user_todos_in_current_org`. +var GetMyTasks = shortcut.Shortcut{ + Service: "todo", + Command: "+get-my-tasks", + Product: "todo", + Description: "查询当前组织下我的待办列表", + Intent: "当你想查看自己在当前组织下的待办清单、盘点未完成事项或按条件筛选任务时使用;可按完成状态、优先级、角色(创建者/执行者/参与者)和截止时间范围过滤并分页,返回匹配的待办列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"}, + {Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"}, + {Name: "status", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "true=已完成, false=未完成"}, + {Name: "priority", Type: shortcut.FlagStringSlice, Desc: "优先级过滤: 10/20/30/40"}, + {Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型: creator/executor/participant"}, + {Name: "plan-finish-start", Type: shortcut.FlagInt, Desc: "截止时间范围开始(Unix 毫秒时间戳)"}, + {Name: "plan-finish-end", Type: shortcut.FlagInt, Desc: "截止时间范围结束(Unix 毫秒时间戳)"}, + }, + Tips: []string{`dws todo +get-my-tasks --status false --priority 40,30`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "pageNum": rt.Str("page"), + "pageSize": rt.Str("size"), + } + if rt.Changed("status") { + params["todoStatus"] = rt.Str("status") + } + if rt.Changed("priority") { + var ps []int + for _, p := range rt.StrSlice("priority") { + if n, err := strconv.Atoi(p); err == nil { + ps = append(ps, n) + } + } + if ps != nil { + params["priorityList"] = ps + } + } + roles := rt.StrSlice("role-types") + if len(roles) == 0 { + roles = []string{"executor"} + } + params["roleTypes"] = roles + if rt.Changed("plan-finish-start") { + params["planFinishDateStart"] = rt.Int("plan-finish-start") + } + if rt.Changed("plan-finish-end") { + params["planFinishDateEnd"] = rt.Int("plan-finish-end") + } + data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", params) + if err != nil { + return err + } + cards := getMyTasksProject(data) + return rt.Output(map[string]any{"count": len(cards), "todos": cards}) + }, +} + +// getMyTasksProject reshapes get_user_todos_in_current_org into a clean todo +// list (subject/taskId/dueTime/priority/done) — clean output projection. +// The card list lives under result.todoCards; fields are probed defensively. +func getMyTasksProject(data map[string]any) []map[string]any { + container := data + if r, ok := data["result"].(map[string]any); ok { + container = r + } + raw, ok := container["todoCards"].([]any) + if !ok { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + for _, k := range []string{"subject", "taskId", "dueTime", "priority", "finalStatusStage", "creatorId"} { + if v, ok := m[k]; ok { + row[k] = v + } + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// ListSub maps helper `list_sub_tasks`. +var ListSub = shortcut.Shortcut{ + Service: "todo", + Command: "+list-sub", + Product: "todo", + Description: "查询子待办列表", + Intent: "当你已知某个待办任务 ID、想了解它被拆解出的所有子任务时使用;输入父任务 ID,返回其下的子待办列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true}, + }, + Tips: []string{`dws todo +list-sub --task-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("todo", "list_sub_tasks", map[string]any{ + "todoSubTaskListRequest": map[string]any{ + "taskId": rt.Str("task-id"), + }, + }) + if err != nil { + return err + } + subs := listSubProject(data) + return rt.Output(map[string]any{"count": len(subs), "subTasks": subs}) + }, +} + +// listSubProject reshapes list_sub_tasks into a clean sub-todo list +// (subject/taskId/dueTime) — clean output projection. The list +// container and field names are probed defensively across candidate keys. +func listSubProject(data map[string]any) []map[string]any { + raw := listSubExtractList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := listSubFirst(m, "subject", "title", "name", "content"); v != nil { + row["subject"] = v + } + if v := listSubFirst(m, "taskId", "id", "subTaskId"); v != nil { + row["taskId"] = v + } + if v := listSubFirst(m, "dueTime", "dueDate", "deadline"); v != nil { + row["dueTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listSubExtractList unwraps common container shapes (a bare slice, or a +// slice nested under result/data/list/items, optionally one level deeper) and +// returns the sub-task slice, or nil when none is found. +func listSubExtractList(data map[string]any) []any { + containers := []map[string]any{data} + for _, k := range []string{"result", "data"} { + if m, ok := data[k].(map[string]any); ok { + containers = append(containers, m) + } + } + for _, c := range containers { + for _, k := range []string{"list", "items", "subTasks", "subTaskList", "result", "data"} { + if arr, ok := c[k].([]any); ok { + return arr + } + } + } + // data itself may be a bare list under result/data. + for _, k := range []string{"result", "data"} { + if arr, ok := data[k].([]any); ok { + return arr + } + } + return nil +} + +// listSubFirst returns the first present value among the candidate keys. +func listSubFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// Update maps helper `update_todo_task`. +// Complete maps helper `update_todo_done_status`. +// Get maps helper `get_todo_detail`. +var Get = shortcut.Shortcut{ + Service: "todo", + Command: "+get", + Product: "todo", + Description: "查询待办详情", + Intent: "当你已知某条待办的任务 ID、想查看它的完整信息(标题、执行者、截止时间、优先级、状态等)时使用;输入任务 ID,返回该待办的详细内容。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true}, + }, + Tips: []string{`dws todo +get --task-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("get_todo_detail", map[string]any{ + "taskId": rt.Str("task-id"), + }) + }, +} + +// Delete maps helper `delete_todo`. +// AddExecutor maps helper `add_task_executors`. +// RemoveExecutor maps helper `remove_task_executors`. +// AddParticipant maps helper `add_task_participants`. +// RemoveParticipant maps helper `remove_task_participants`. +// AddReminder maps helper `add_todo_reminder`. +// ListAttachment maps helper `list_todo_attachment`. +var ListAttachment = shortcut.Shortcut{ + Service: "todo", + Command: "+list-attachment", + Product: "todo", + Description: "查询待办任务的附件列表", + Intent: "当你想查看某条待办上挂了哪些附件、或需要拿到附件 ID 以便后续删除时使用;输入任务 ID,返回该待办的附件列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true}, + }, + Tips: []string{`dws todo +list-attachment --task-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("todo", "list_todo_attachment", map[string]any{ + "todoAttachmentListRequest": map[string]any{ + "taskId": rt.Str("task-id"), + }, + }) + if err != nil { + return err + } + atts := listAttachmentProject(data) + return rt.Output(map[string]any{"count": len(atts), "attachments": atts}) + }, +} + +// listAttachmentProject reshapes list_todo_attachment into a clean attachment +// list (attachmentId/fileName/fileSize/fileType) — output-projection fidelity +// for clean output. The list container and field names are probed defensively across +// candidate keys so response-shape drift yields an empty list, not a crash. +func listAttachmentProject(data map[string]any) []map[string]any { + raw := listAttachmentExtractList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := listAttachmentFirst(m, "attachmentId", "id", "fileId"); v != nil { + row["attachmentId"] = v + } + if v := listAttachmentFirst(m, "fileName", "name", "spaceFileName"); v != nil { + row["fileName"] = v + } + if v := listAttachmentFirst(m, "fileSize", "size"); v != nil { + row["fileSize"] = v + } + if v := listAttachmentFirst(m, "fileType", "type"); v != nil { + row["fileType"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listAttachmentExtractList unwraps a bare slice or one nested under common +// envelope keys (optionally one level deeper), returning nil when none found. +func listAttachmentExtractList(data map[string]any) []any { + containers := []map[string]any{data} + for _, k := range []string{"result", "data"} { + if m, ok := data[k].(map[string]any); ok { + containers = append(containers, m) + } + } + for _, c := range containers { + for _, k := range []string{"list", "items", "attachments", "attachmentList", "result", "data"} { + if arr, ok := c[k].([]any); ok { + return arr + } + } + } + for _, k := range []string{"result", "data"} { + if arr, ok := data[k].([]any); ok { + return arr + } + } + return nil +} + +// listAttachmentFirst returns the first present value among the candidate keys. +func listAttachmentFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// RemoveAttachment maps helper `remove_todo_attachment`. +// AddComment maps helper `add_todo_comment`. +// ListComment maps helper `list_todo_comment`. +var ListComment = shortcut.Shortcut{ + Service: "todo", + Command: "+list-comment", + Product: "todo", + Description: "查询待办评论列表", + Intent: "当你想查看某条待办下的讨论记录、了解协作沟通历史或获取评论 ID 以便删除时使用;输入任务 ID 并可分页,返回该待办的评论列表。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true}, + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"}, + {Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"}, + }, + Tips: []string{`dws todo +list-comment --task-id `}, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("todo", "list_todo_comment", map[string]any{ + "taskId": rt.Str("task-id"), + "page": rt.Str("page"), + "pageSize": rt.Str("size"), + }) + if err != nil { + return err + } + comments := listCommentProject(data) + return rt.Output(map[string]any{"count": len(comments), "comments": comments}) + }, +} + +// listCommentProject reshapes list_todo_comment into a clean comment list +// (commentId/content/creatorId/createTime) — clean output projection. +// The list container and field names are probed defensively across +// candidate keys so response-shape drift yields an empty list, not a crash. +func listCommentProject(data map[string]any) []map[string]any { + raw := listCommentExtractList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := listCommentFirst(m, "commentId", "id"); v != nil { + row["commentId"] = v + } + if v := listCommentFirst(m, "content", "text", "comment"); v != nil { + row["content"] = v + } + if v := listCommentFirst(m, "creatorId", "creator", "userId"); v != nil { + row["creatorId"] = v + } + if v := listCommentFirst(m, "createTime", "createdTime", "gmtCreate"); v != nil { + row["createTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// listCommentExtractList unwraps a bare slice or one nested under common +// envelope keys (optionally one level deeper), returning nil when none found. +func listCommentExtractList(data map[string]any) []any { + containers := []map[string]any{data} + for _, k := range []string{"result", "data"} { + if m, ok := data[k].(map[string]any); ok { + containers = append(containers, m) + } + } + for _, c := range containers { + for _, k := range []string{"list", "items", "comments", "commentList", "result", "data"} { + if arr, ok := c[k].([]any); ok { + return arr + } + } + } + for _, k := range []string{"result", "data"} { + if arr, ok := data[k].([]any); ok { + return arr + } + } + return nil +} + +// listCommentFirst returns the first present value among the candidate keys. +func listCommentFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// DeleteComment maps helper `delete_todo_comment`. +func init() { + shortcut.Register( + GetMyTasks, + ListSub, + Get, + ListAttachment, + ListComment, + ) +} diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go new file mode 100644 index 00000000..6c2dfa6d --- /dev/null +++ b/internal/shortcut/wiki/wiki.go @@ -0,0 +1,312 @@ +// 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 wiki declares high-fidelity shortcuts for the DingTalk wiki +// (knowledge base) service: space management, member management and node +// management. Tool names and parameters mirror internal/helpers/wiki.go. +package wiki + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + +// ── space (知识库) ──────────────────────────────────────────── + +// SpaceCreate → create_wikiSpace +// SpaceGet → get_wikiSpace +// SpaceList → list_wikiSpaces +var SpaceList = shortcut.Shortcut{ + Service: "wiki", + Command: "+space-list", + Product: "wiki", + Description: "列出组织 / 个人知识库", + Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"}, + }, + Tips: []string{ + `dws wiki +space-list`, + `dws wiki +space-list --type myWikiSpace`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("type") { + params["wikiSpaceType"] = rt.Str("type") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Str("limit") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params) + if err != nil { + return err + } + spaces := spaceListProject(data) + return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) + }, +} + +// spaceListProject reshapes list_wikiSpaces into a clean space list +// ({workspaceId, name, description, createTime}) — output-projection fidelity +// for clean output. The list container and per-item field names are probed defensively +// across candidate keys, so an unrecognized shape yields an empty list. +func spaceListProject(data map[string]any) []map[string]any { + raw := wikiSpaceRawList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil { + row["workspaceId"] = v + } + if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil { + row["name"] = v + } + if v := wikiSpaceFirst(m, "description", "desc"); v != nil { + row["description"] = v + } + if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil { + row["createTime"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// wikiSpaceRawList locates the space array across candidate container keys, +// tolerating a nested {result|data:{list|items|spaces}} wrapper. +func wikiSpaceRawList(data map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} { + if arr, ok := data[k].([]any); ok { + return arr + } + if inner, ok := data[k].(map[string]any); ok { + for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return nil +} + +// wikiSpaceFirst returns the first present value among candidate keys. +func wikiSpaceFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// SpaceSearch → search_wikiSpaces +var SpaceSearch = shortcut.Shortcut{ + Service: "wiki", + Command: "+space-search", + Product: "wiki", + Description: "搜索知识库", + Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"}, + }, + Tips: []string{`dws wiki +space-search --query "产品文档"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"keyword": rt.Str("query")} + if rt.Changed("limit") { + params["pageSize"] = rt.Str("limit") + } + data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params) + if err != nil { + return err + } + spaces := spaceListProject(data) + return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) + }, +} + +// SpaceDelete → delete_wikiSpace +// ── member (知识库成员) ─────────────────────────────────────── + +// MemberAdd → add_member +// MemberUpdate → update_member +// MemberList → list_member +// MemberRemove → remove_member +// ── node (知识库节点,路由到 doc MCP server) ────────────────── + +// NodeList → list_nodes (doc) +var NodeList = shortcut.Shortcut{ + Service: "wiki", + Command: "+node-list", + Product: "doc", + Description: "列出知识库节点", + Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Tips: []string{`dws wiki +node-list --workspace --folder `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceId": rt.Str("workspace")} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if rt.Changed("limit") { + params["pageSize"] = rt.Int("limit") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("wiki", "list_nodes", params) + if err != nil { + return err + } + nodes := nodeListProject(data) + return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes}) + }, +} + +// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type) +// — clean output projection. Container and field keys are probed +// defensively across candidate aliases; an unrecognized shape yields an empty list. +func nodeListProject(data map[string]any) []map[string]any { + raw := nodeListRawList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{} + if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil { + row["name"] = v + } + if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil { + row["nodeId"] = v + } + if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil { + row["type"] = v + } + if len(row) > 0 { + out = append(out, row) + } + } + return out +} + +// nodeListRawList locates the node array across candidate container keys, +// tolerating a nested {result|data:{list|items|nodes}} wrapper. +func nodeListRawList(data map[string]any) []any { + for _, k := range []string{"result", "data", "list", "items", "nodes"} { + if arr, ok := data[k].([]any); ok { + return arr + } + if inner, ok := data[k].(map[string]any); ok { + for _, ik := range []string{"list", "items", "nodes", "result", "data"} { + if arr, ok := inner[ik].([]any); ok { + return arr + } + } + } + } + return nil +} + +// nodeListFirst returns the first present value among candidate keys. +func nodeListFirst(m map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := m[k]; ok { + return v + } + } + return nil +} + +// NodeCreate → create_file (doc) +// NodeCopy → copy_document (doc) +var NodeCopy = shortcut.Shortcut{ + Service: "wiki", + Command: "+node-copy", + Product: "doc", + Description: "复制知识库节点", + Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, + {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"}, + }, + Tips: []string{`dws wiki +node-copy --workspace --node --folder `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "nodeId": rt.Str("node"), + "workspaceId": rt.Str("workspace"), + } + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + return rt.CallMCP("copy_document", params) + }, +} + +// NodeMove → move_document (doc) +var NodeMove = shortcut.Shortcut{ + Service: "wiki", + Command: "+move", + Product: "doc", + Description: "移动知识库节点", + Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, + {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"}, + }, + Tips: []string{`dws wiki +move --workspace --node --folder `}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "nodeId": rt.Str("node"), + "workspaceId": rt.Str("workspace"), + } + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + return rt.CallMCP("move_document", params) + }, +} + +// NodeDelete → delete_document (doc) +// NodeSearch → search_documents (doc) +func init() { + shortcut.Register( + SpaceList, + SpaceSearch, + NodeList, + NodeCopy, + NodeMove, + ) +}