diff --git a/README.md b/README.md index 371143fe..de1b8f5e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex ``` +
+Skill mode: mono vs multi + +The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs. + +| Mode | What gets installed | Best for | +|------|----------------------|----------| +| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point | +| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call | + +> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems. + +How to pick: + +- **Quick install** (one-liner above): non-interactive, installs `mono`. +- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1). +- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`. +- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time. + +
+
Other install methods @@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10 ### Agent Skills -The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language: +The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts: + +- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default. +- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.** + +After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language: ```bash -# Install skills into current project +# Install skills into current project (defaults to mono) curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` > `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project). -**What's included:** +**Switching or re-installing with `dws skill setup`:** + +```bash +# Interactive: prompts for mode + target agents +dws skill setup + +# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder) +dws skill setup --mode mono --target all --yes + +# Install multi skills to a single agent home +dws skill setup --mode multi --target cursor --yes + +# Point at a local source tree (e.g. a fork or work-in-progress) +DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi +``` + +| Flag | Values | Description | +|------|--------|-------------| +| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt | +| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home | +| `--source` | path | Local source directory (overrides bundled skills) | +| `--yes` | — | Skip confirmation prompts | + +Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=`. + +**What's included (mono layout):** | Component | Path | Description | |-----------|------|-------------| -| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling | -| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) | -| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) | -| Global reference | `references/global-reference.md` | Auth, output formats, global flags | -| Error codes | `references/error-codes.md` | Error codes + debugging workflows | -| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling | -| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) | +| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling | +| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) | +| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) | +| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags | +| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows | +| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling | +| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
Ready-made scripts — 13 Python scripts for common multi-step workflows @@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
Coming soon -`conference` (video meetings) +- `conference` (video meetings) +- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
diff --git a/README_zh.md b/README_zh.md index 1ef2b472..5d0d861e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex ``` +
+Skill 模式:mono 与 multi + +安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。 + +| 模式 | 安装内容 | 适合场景 | +|------|----------|----------| +| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 | +| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 | + +> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。 + +怎么选: + +- **快速安装**(上方一行 curl):非交互,默认装 `mono`。 +- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。 +- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。 +- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。 + +
+
其他安装方式 @@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10 ### Agent Skills -仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉: +仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局: + +- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。 +- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 20 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。** + +安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉: ```bash -# 安装 skills 到当前项目 +# 安装 skills 到当前项目(默认 mono) curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` > `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。 -**包含内容:** +**用 `dws skill setup` 切换或重装:** + +```bash +# 交互式:提示选模式 + 目标 Agent +dws skill setup + +# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder) +dws skill setup --mode mono --target all --yes + +# 只装到某一个 Agent home +dws skill setup --mode multi --target cursor --yes + +# 指定本地源目录(比如 fork 或正在改的版本) +DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi +``` + +| 参数 | 取值 | 说明 | +|------|------|------| +| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 | +| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home | +| `--source` | 路径 | 本地源目录(覆盖内置 skills) | +| `--yes` | — | 跳过确认提示 | + +环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。 + +**包含内容(mono 布局):** | 组件 | 路径 | 说明 | |------|------|------| -| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 | -| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) | -| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) | -| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag | -| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 | -| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 | -| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) | +| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 | +| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) | +| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) | +| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag | +| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 | +| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 | +| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
现成脚本 — 13 个 Python 脚本,覆盖常见多步工作流 @@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
即将推出 -`conference`(视频会议) +- `conference`(视频会议) +- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
diff --git a/build/npm/install.js b/build/npm/install.js index 2861256e..7935c27e 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -136,11 +136,36 @@ function installSkillsToHomes(skillRoot) { } } +// cacheUserSkills copies the mono and multi trees out of the freshly extracted +// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup` +// can fall back to a user-local cache when --source is not provided. mono is +// already installed into agent homes by installSkillsToHomes; the cache is +// purely a source-of-truth for the setup command. +function cacheUserSkills(extractedSkillsRoot) { + const cacheBase = path.join(os.homedir(), ".dws", "skills"); + + const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md")) + ? path.join(extractedSkillsRoot, "mono") + : extractedSkillsRoot; + const monoCache = path.join(cacheBase, "mono"); + fs.rmSync(monoCache, { recursive: true, force: true }); + copyChildren(monoSource, monoCache); + + const multiSource = path.join(extractedSkillsRoot, "multi"); + if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) { + const multiCache = path.join(cacheBase, "multi"); + fs.rmSync(multiCache, { recursive: true, force: true }); + copyChildren(multiSource, multiCache); + } +} + function main() { const packageRoot = __dirname; const assetsDir = path.join(packageRoot, "assets"); const vendorDir = path.join(packageRoot, "vendor"); - const skillDir = path.join(packageRoot, "share", "skills", "dws"); + // Extract dws-skills.zip into a staging directory so we can split mono/ + // (installed to agent homes) from multi/ (cached for later setup use). + const skillsStaging = path.join(packageRoot, "share", "skills"); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (!assetName) { throw new Error(`unsupported platform: ${process.platform}/${process.arch}`); @@ -156,8 +181,16 @@ function main() { } extractArchive(archivePath, vendorDir); - extractSkills(skillsPath, skillDir); - installSkillsToHomes(skillDir); + extractSkills(skillsPath, skillsStaging); + + // For backward compatibility, the zip root carries a copy of mono content + // (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir + // when present; fall back to the staging root otherwise. + const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md")) + ? path.join(skillsStaging, "mono") + : skillsStaging; + installSkillsToHomes(monoRoot); + cacheUserSkills(skillsStaging); } main(); diff --git a/go.mod b/go.mod index d3fd55e5..f9963dc2 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,37 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.20.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 4e538ff1..a141d285 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,40 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -15,13 +47,29 @@ github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -31,11 +79,16 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8d1342d0..53e0decb 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -24,6 +24,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "time" @@ -85,24 +86,80 @@ type CliSkillDTO struct { // agentSkillPaths maps target names to their relative skill installation paths. // These paths are relative to the user's home directory. +// +// Source of truth for both `dws skill install ` and +// `dws skill setup --target `. Every entry in skillSetupAgentHomes +// (skill_setup.go) MUST have a matching path value here — enforced by +// TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ + // `agents` is the generic-agent sentinel: install scripts and `setup` + // special-case ~/.agents/skills as a no-checks-required fallback so a + // fresh machine without any IDE/agent registry still gets skills. + "agents": ".agents/skills", "qoder": ".qoder/skills", "claude": ".claude/skills", "cursor": ".cursor/skills", "codex": ".codex/skills", "opencode": filepath.Join(".config", "opencode", "skills"), + // IDE / agent registries also probed by `dws skill setup --target all`. + "gemini": ".gemini/skills", + "github": ".github/skills", + "windsurf": ".windsurf/skills", + "augment": ".augment/skills", + "cline": ".cline/skills", + "amp": ".amp/skills", + "kiro": ".kiro/skills", + "trae": ".trae/skills", + "openclaw": ".openclaw/skills", + "hermes": ".hermes/skills", } -// supportedTargets returns a comma-separated list of supported targets. +// supportedTargets returns a sorted, comma-separated list of supported +// targets. Sorted so help text and error messages stay stable across runs +// (Go map iteration order is intentionally randomized). func supportedTargets() string { targets := make([]string, 0, len(agentSkillPaths)+1) for target := range agentSkillPaths { targets = append(targets, target) } + sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") } +// longestAgentTargetName returns the character count of the longest target +// name in agentSkillPaths. Used by --help formatting to keep the "." entry +// vertically aligned with named targets. +func longestAgentTargetName() int { + n := 0 + for name := range agentSkillPaths { + if len(name) > n { + n = len(name) + } + } + return n +} + +// formatAgentSkillPathsForHelp renders agentSkillPaths as an aligned +// " -> ~/" block, sorted by name, for use in --help output. +// Keeps `dws skill install --help` in sync with the map without hand-edits. +func formatAgentSkillPathsForHelp() string { + names := make([]string, 0, len(agentSkillPaths)) + maxWidth := 0 + for n := range agentSkillPaths { + names = append(names, n) + if len(n) > maxWidth { + maxWidth = len(n) + } + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + } + return b.String() +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -122,6 +179,7 @@ func buildSkillCommand() *cobra.Command { newSkillSearchCommand(), newSkillFindHintCommand(), newSkillAddHintCommand(), + newSkillSetupCommand(), ) return cmd } @@ -179,17 +237,15 @@ func newSkillInstallCommand() *cobra.Command { target 安装目标(必填),支持: %s 安装路径: - qoder -> ~/.qoder/skills/ - claude -> ~/.claude/skills/ - cursor -> ~/.cursor/skills/ - codex -> ~/.codex/skills/ - opencode -> ~/.config/opencode/skills/ - . -> 当前目录 +%s .%s -> 当前目录 示例: - dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/ dws skill install skill-123 claude # 安装到 ~/.claude/skills/ - dws skill install skill-123 . # 安装到当前目录`, supportedTargets()), + dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/ + dws skill install skill-123 . # 安装到当前目录`, + supportedTargets(), + formatAgentSkillPathsForHelp(), + strings.Repeat(" ", longestAgentTargetName()-1)), Args: cobra.ExactArgs(2), DisableAutoGenTag: true, RunE: runSkillAdd, diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 4f1c6b55..9daf8c4e 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -452,8 +452,14 @@ func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) { func TestSupportedTargets(t *testing.T) { targets := supportedTargets() - // Should contain all predefined targets - expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."} + // Should contain all predefined targets — including the agents/* sentinel + // and the IDE/agent registries we share with skillSetupAgentHomes. + expectedTargets := []string{ + "agents", "claude", "cursor", "codex", "opencode", "qoder", + "gemini", "github", "windsurf", "augment", "cline", + "amp", "kiro", "trae", "openclaw", "hermes", + ".", + } for _, expected := range expectedTargets { if !strings.Contains(targets, expected) { t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets) @@ -461,6 +467,27 @@ func TestSupportedTargets(t *testing.T) { } } +// TestAgentSkillPathsCoversSetupHomes guards against drift between +// agentSkillPaths (used by `dws skill install` and `dws skill setup +// --target `) and skillSetupAgentHomes (used by `dws skill setup +// --target all` to detect candidate agent homes). +// +// Every path in skillSetupAgentHomes MUST be reachable via at least one +// entry in agentSkillPaths — otherwise `--target all` would silently +// install into agent homes that the user cannot address by name. +func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { + paths := make(map[string]bool, len(agentSkillPaths)) + for _, p := range agentSkillPaths { + paths[p] = true + } + for _, home := range skillSetupAgentHomes { + if !paths[home] { + t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ + "add it to agentSkillPaths so users can address it via --target ", home) + } + } +} + func TestAgentSkillPathsCrossPlatform(t *testing.T) { // Verify that paths use platform-appropriate separators for target, path := range agentSkillPaths { diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go new file mode 100644 index 00000000..4a43f195 --- /dev/null +++ b/internal/app/skill_setup.go @@ -0,0 +1,647 @@ +package app + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" +) + +// skillSetupAgentHomes is the ordered list of agent home subdirectories +// where dws skills get installed. Mirrors install.sh / install.ps1 / +// build/npm/install.js so that `dws skill setup` and the install scripts +// agree on the install footprint. +var skillSetupAgentHomes = []string{ + ".agents/skills", + ".claude/skills", + ".cursor/skills", + ".gemini/skills", + ".codex/skills", + ".github/skills", + ".windsurf/skills", + ".augment/skills", + ".cline/skills", + ".amp/skills", + ".kiro/skills", + ".trae/skills", + ".openclaw/skills", + ".hermes/skills", +} + +const ( + skillSetupModeMono = "mono" + skillSetupModeMulti = "multi" +) + +func newSkillSetupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "setup", + Short: "安装 dws 自身 skill 到 Agent 目录", + Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。 + +支持两种模式: + mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/ + multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill + 尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动; + 生产前请评估,问题请提 issue 反馈 + +multi 模式支持按产品挑选: + -s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可) + -x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥) + 未列出的已有 dingtalk-* skill 会保留(additive 叠加语义) + +不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`, + Example: ` dws skill setup # 交互式 + dws skill setup --mode mono --yes # 非交互装 mono + dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/ + dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar + dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个 + dws skill setup --source /path/to/repo # 显式指定 skill 源`, + DisableAutoGenTag: true, + RunE: runSkillSetup, + } + cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)") + cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets()) + cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)") + cmd.Flags().Bool("yes", false, "跳过所有确认提示") + cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)") + cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)") + return cmd +} + +func runSkillSetup(cmd *cobra.Command, _ []string) error { + mode, _ := cmd.Flags().GetString("mode") + target, _ := cmd.Flags().GetString("target") + source, _ := cmd.Flags().GetString("source") + autoYes, _ := cmd.Flags().GetBool("yes") + includeRaw, _ := cmd.Flags().GetStringSlice("skill") + excludeRaw, _ := cmd.Flags().GetStringSlice("exclude") + + out := cmd.OutOrStdout() + errOut := cmd.ErrOrStderr() + + mode, err := resolveSkillSetupMode(mode, autoYes, out) + if err != nil { + return err + } + + if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) { + return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)") + } + + skillSrc, err := resolveSkillSetupSource(source, mode) + if err != nil { + return err + } + + dests, err := resolveSkillSetupTargets(target, mode) + if err != nil { + return err + } + + // multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用 + var multiSkillNames []string + if mode == skillSetupModeMulti { + allMultiSkillNames, listErr := listMultiSkillNames(skillSrc) + if listErr != nil { + return listErr + } + if len(allMultiSkillNames) == 0 { + return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc) + } + filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw) + if filterErr != nil { + return filterErr + } + multiSkillNames = filtered + } + + if !autoYes { + ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(out, "已取消。") + return nil + } + } else if mode == skillSetupModeMulti { + fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono") + } + + var installed, skipped int + switch mode { + case skillSetupModeMono: + installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut) + case skillSetupModeMulti: + installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut) + default: + return fmt.Errorf("内部错误:未知 mode %q", mode) + } + if err != nil { + return err + } + + fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) + return nil +} + +// multiSkillPrefix is the canonical prefix for every per-product skill +// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar). +const multiSkillPrefix = "dingtalk-" + +// normalizeMultiSkillName accepts either the short form (aitable) or the +// full form (dingtalk-aitable) and returns the canonical full form. +// Empty input returns "". Comparison is case-insensitive. +func normalizeMultiSkillName(name string) string { + n := strings.ToLower(strings.TrimSpace(name)) + if n == "" { + return "" + } + if strings.HasPrefix(n, multiSkillPrefix) { + return n + } + return multiSkillPrefix + n +} + +// filterMultiSkillNames narrows `all` by include / exclude lists. +// Semantics mirror lark-cli's `npx skills add -s lark-calendar`: +// +// - include + exclude are mutually exclusive (both → error) +// - names accept short or full form; normalized before matching +// - unknown names → error, with the available list inlined for discovery +// - both lists empty → return `all` (install everything) +// - exclude that drops every name → error (avoid silent no-op install) +// +// The caller is responsible for additive installation: install only the +// returned names, leaving any other already-installed dingtalk-* siblings +// untouched (handled by installMultiSkillToHomes which does not enumerate +// the destination). +func filterMultiSkillNames(all, include, exclude []string) ([]string, error) { + if len(include) > 0 && len(exclude) > 0 { + return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用") + } + + available := make(map[string]struct{}, len(all)) + for _, n := range all { + available[n] = struct{}{} + } + + validate := func(raw []string, flagName string) ([]string, error) { + var normalized []string + var unknown []string + seen := make(map[string]bool) + for _, r := range raw { + n := normalizeMultiSkillName(r) + if n == "" { + continue + } + if _, ok := available[n]; !ok { + unknown = append(unknown, r) + continue + } + if !seen[n] { + seen[n] = true + normalized = append(normalized, n) + } + } + if len(unknown) > 0 { + return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s", + flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", ")) + } + return normalized, nil + } + + if len(include) > 0 { + names, err := validate(include, "--skill") + if err != nil { + return nil, err + } + sort.Strings(names) + return names, nil + } + if len(exclude) > 0 { + excluded, err := validate(exclude, "--exclude") + if err != nil { + return nil, err + } + excludedSet := make(map[string]bool, len(excluded)) + for _, n := range excluded { + excludedSet[n] = true + } + var out []string + for _, n := range all { + if !excludedSet[n] { + out = append(out, n) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all)) + } + return out, nil + } + return all, nil +} + +// listMultiSkillNames returns sorted names of subdirectories under src that +// contain a SKILL.md file (i.e. valid multi-mode skill bundles). +func listMultiSkillNames(src string) ([]string, error) { + entries, err := os.ReadDir(src) + if err != nil { + return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err) + } + var names []string + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names, nil +} + +// resolveSkillSetupMode resolves the mode either from the flag or via an +// interactive prompt. If no TTY is available and no mode was given, returns +// an error rather than silently picking a default. +func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) { + mode = strings.ToLower(strings.TrimSpace(mode)) + switch mode { + case skillSetupModeMono, skillSetupModeMulti: + return mode, nil + case "": + // fall through to interactive prompt + default: + return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode) + } + + if autoYes || !isInteractiveTerminal() { + fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono") + return skillSetupModeMono, nil + } + + var choice string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("选择 dws skill 安装模式"). + Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)"). + Options( + huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono), + huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti), + ). + Value(&choice), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("交互式选择中止: %w", err) + } + return choice, nil +} + +// resolveSkillSetupSource finds the local skill source directory for the +// given mode. PR 1 supports only mono; multi is reserved for a later PR +// and currently returns an error before reaching this function. +func resolveSkillSetupSource(explicit, mode string) (string, error) { + subdir := mode // "mono" or "multi" + + candidates := skillSourceCandidates(explicit, subdir) + for _, c := range candidates { + if isSkillSourceRoot(c, mode) { + return c, nil + } + } + + hint := strings.Join(candidates, "\n - ") + return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode) +} + +// skillSourceCandidates returns the ordered list of paths to probe for a +// skill source root, given an optional explicit override and the mode +// subdir (mono or multi). +func skillSourceCandidates(explicit, subdir string) []string { + var roots []string + if explicit != "" { + // allow either repo root or already-resolved skills/ dir + roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir)) + } + if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" { + roots = append(roots, env, filepath.Join(env, "skills", subdir)) + } + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + roots = append(roots, + filepath.Join(exeDir, "skills", subdir), + filepath.Join(exeDir, "..", "skills", subdir), + filepath.Join(exeDir, "..", "share", "skills", "dws"), + ) + } + if wd, err := os.Getwd(); err == nil { + roots = append(roots, filepath.Join(wd, "skills", subdir)) + } + // User-level cache populated by install.sh / install.ps1 / npm install.js + // from the dws-skills.zip release asset. Lets `dws skill setup` find a + // source even when the user has no source checkout on disk. + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, filepath.Join(home, ".dws", "skills", subdir)) + } + return roots +} + +func isSkillSourceRoot(path, mode string) bool { + if path == "" { + return false + } + switch mode { + case skillSetupModeMono: + fi, err := os.Stat(filepath.Join(path, "SKILL.md")) + return err == nil && !fi.IsDir() + case skillSetupModeMulti: + entries, err := os.ReadDir(path) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() { + if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil { + return true + } + } + } + return false + } + return false +} + +// resolveSkillSetupTargets returns the list of absolute Agent home destinations. +// If target == "all", returns every agent home whose parent directory exists. +// Otherwise returns the single matching home (whether or not it currently exists). +// +// 末段约定: +// - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) +// - multi → (安装时把 src 下每个子目录拷成兄弟 skill) +func resolveSkillSetupTargets(target, mode string) ([]string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("无法解析用户 HOME: %w", err) + } + + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" || target == "all" { + return detectExistingAgentHomes(home, mode), nil + } + + rel, ok := agentSkillPaths[target] + if !ok { + return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) + } + return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil +} + +// agentHomeForMode appends the mode-specific tail segment to an agent home base. +func agentHomeForMode(base, mode string) string { + if mode == skillSetupModeMulti { + return base + } + return filepath.Join(base, "dws") +} + +func detectExistingAgentHomes(home, mode string) []string { + var out []string + for i, rel := range skillSetupAgentHomes { + base := filepath.Join(home, rel) + parent := filepath.Dir(base) + if i > 0 { + if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) { + continue + } + } + out = append(out, agentHomeForMode(base, mode)) + } + if len(out) == 0 { + out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode)) + } + return out +} + +func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) { + if mode == skillSetupModeMulti { + fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────") + fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)") + fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable") + fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整") + fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈") + fmt.Fprintln(out, " 稳定版请用 --mode mono") + fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────") + } + fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src) + if mode == skillSetupModeMulti { + fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 //):\n", len(multiSkillNames)) + for _, n := range multiSkillNames { + fmt.Fprintf(out, " · %s\n", n) + } + } + fmt.Fprintln(out, " destinations:") + for _, d := range dests { + fmt.Fprintf(out, " - %s\n", d) + } + // 列出互斥清理:装 mode 前要把对面 mode 的残留删掉 + fmt.Fprintln(out, " 互斥清理(确认后才执行):") + for _, d := range dests { + for _, victim := range mutualExclusionVictims(d, mode) { + fmt.Fprintf(out, " × 将删除 %s\n", victim) + } + } + + if !isInteractiveTerminal() { + return true, nil + } + + var confirm bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("确认安装?"). + Affirmative("继续"). + Negative("取消"). + Value(&confirm), + ), + ) + if err := form.Run(); err != nil { + return false, fmt.Errorf("确认中止: %w", err) + } + return confirm, nil +} + +// mutualExclusionVictims returns the paths that should be removed before +// installing into dest under the given mode, to prevent leftover files from +// the opposite mode from co-existing. +// +// - mono dest is /dws → multi 残留是 /dingtalk-* +// - multi dest is → mono 残留是 /dws +func mutualExclusionVictims(dest, mode string) []string { + switch mode { + case skillSetupModeMono: + // dest = /dws → agent-home = parent + agentHome := filepath.Dir(dest) + entries, err := os.ReadDir(agentHome) + if err != nil { + return nil + } + var victims []string + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") { + victims = append(victims, filepath.Join(agentHome, e.Name())) + } + } + sort.Strings(victims) + return victims + case skillSetupModeMulti: + // dest = → mono 残留是 dest/dws + monoPath := filepath.Join(dest, "dws") + if _, err := os.Stat(monoPath); err == nil { + return []string{monoPath} + } + return nil + } + return nil +} + +// cleanupMutualExclusion best-effort removes the opposite-mode leftovers. +// Failures emit a warning to errOut but never abort the install. +func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) { + for _, victim := range mutualExclusionVictims(dest, mode) { + if err := os.RemoveAll(victim); err != nil { + fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err) + continue + } + fmt.Fprintf(out, " × 已清理对面模式残留 %s\n", victim) + } +} + +func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) { + sort.Strings(dests) + for _, dest := range dests { + // 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉 + cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut) + + if err := os.RemoveAll(dest); err != nil { + fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err) + skipped++ + continue + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err) + skipped++ + continue + } + if err := copyDir(src, dest); err != nil { + fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err) + skipped++ + continue + } + fmt.Fprintf(out, " ✓ %s\n", dest) + installed++ + } + return installed, skipped, nil +} + +// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into +// dest as a sibling skill directory. installed/skipped is counted per +// (agent-home × sub-skill) pair so the user sees granular progress. +func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) { + sort.Strings(dests) + for _, dest := range dests { + // 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留) + cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut) + + if err := os.MkdirAll(dest, 0o755); err != nil { + fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err) + skipped += len(skillNames) + continue + } + + for _, name := range skillNames { + subSrc := filepath.Join(src, name) + subDest := filepath.Join(dest, name) + if err := os.RemoveAll(subDest); err != nil { + fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err) + skipped++ + continue + } + if err := copyDir(subSrc, subDest); err != nil { + fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err) + skipped++ + continue + } + fmt.Fprintf(out, " ✓ %s\n", subDest) + installed++ + } + } + return installed, skipped, nil +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + if info.Mode()&os.ModeSymlink != 0 { + // resolve symlink target and copy the underlying file + resolved, err := os.Readlink(path) + if err != nil { + return err + } + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(filepath.Dir(path), resolved) + } + return copyFileContent(resolved, target, info.Mode()) + } + return copyFileContent(path, target, info.Mode()) + }) +} + +func copyFileContent(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func isInteractiveTerminal() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go new file mode 100644 index 00000000..6d28c9f9 --- /dev/null +++ b/internal/app/skill_setup_test.go @@ -0,0 +1,559 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSkillSetupCommandRegistered(t *testing.T) { + root := buildSkillCommand() + var found bool + for _, sub := range root.Commands() { + if sub.Name() == "setup" { + found = true + break + } + } + if !found { + t.Fatalf("dws skill setup not registered as subcommand") + } +} + +func TestResolveSkillSetupModeFlagDirect(t *testing.T) { + got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{}) + if err != nil || got != skillSetupModeMono { + t.Fatalf("expected mono no-error, got %q err=%v", got, err) + } + got, err = resolveSkillSetupMode("MULTI", true, &bytes.Buffer{}) + if err != nil || got != skillSetupModeMulti { + t.Fatalf("expected multi case-insensitive, got %q err=%v", got, err) + } + if _, err = resolveSkillSetupMode("hybrid", true, &bytes.Buffer{}); err == nil { + t.Fatalf("expected error on invalid mode") + } +} + +func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) { + var buf bytes.Buffer + got, err := resolveSkillSetupMode("", true, &buf) + if err != nil || got != skillSetupModeMono { + t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err) + } + if !strings.Contains(buf.String(), "mono") { + t.Fatalf("expected output to mention mono fallback, got %q", buf.String()) + } +} + +func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) { + tmp := t.TempDir() + monoDir := filepath.Join(tmp, "skills", "mono") + if err := os.MkdirAll(monoDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(monoDir, "SKILL.md"), []byte("# test"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := resolveSkillSetupSource(tmp, skillSetupModeMono) + if err != nil { + t.Fatalf("expected to find mono source, got err=%v", err) + } + if got != monoDir { + t.Fatalf("expected %s, got %s", monoDir, got) + } +} + +func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("DWS_SKILL_SOURCE", "") + // Isolate HOME so the ~/.dws/skills// fallback (added by the release + // pipeline cache work) does not pick up real cached content on the + // developer machine. + t.Setenv("HOME", t.TempDir()) + _, err := resolveSkillSetupSource(tmp, skillSetupModeMono) + if err == nil { + t.Fatalf("expected error when source missing") + } + if !strings.Contains(err.Error(), "未找到") { + t.Fatalf("expected 未找到 message, got %v", err) + } +} + +func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { + got, err := resolveSkillSetupTargets("claude", skillSetupModeMono) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 dest, got %d", len(got)) + } + if !strings.Contains(got[0], ".claude/skills/dws") { + t.Fatalf("expected .claude/skills/dws path, got %s", got[0]) + } +} + +func TestResolveSkillSetupTargetsUnknown(t *testing.T) { + if _, err := resolveSkillSetupTargets("nonsense", skillSetupModeMono); err == nil { + t.Fatalf("expected error for unknown target") + } +} + +func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { + got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 dest, got %d", len(got)) + } + if strings.HasSuffix(got[0], "/dws") { + t.Fatalf("multi target must not end with /dws, got %s", got[0]) + } + if !strings.HasSuffix(got[0], ".claude/skills") { + t.Fatalf("expected suffix .claude/skills, got %s", got[0]) + } +} + +func TestInstallSkillToHomesEndToEnd(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(src, "references"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "x.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + dst1 := filepath.Join(t.TempDir(), "a", "dws") + dst2 := filepath.Join(t.TempDir(), "b", "dws") + + var stdout, stderr bytes.Buffer + installed, skipped, err := installSkillToHomes(src, []string{dst1, dst2}, &stdout, &stderr) + if err != nil { + t.Fatalf("install err: %v", err) + } + if installed != 2 || skipped != 0 { + t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped) + } + for _, d := range []string{dst1, dst2} { + if _, err := os.Stat(filepath.Join(d, "SKILL.md")); err != nil { + t.Fatalf("missing SKILL.md in %s: %v", d, err) + } + if _, err := os.Stat(filepath.Join(d, "references", "x.md")); err != nil { + t.Fatalf("missing references/x.md in %s: %v", d, err) + } + } +} + +// writeMultiSkillSource builds a fake skills/multi/ layout containing N +// dingtalk-* subdirs, each with a SKILL.md and one references/.md +// file. Returns the absolute skill source root. +func writeMultiSkillSource(t *testing.T, names []string) string { + t.Helper() + root := t.TempDir() + for _, n := range names { + sub := filepath.Join(root, n) + if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestInstallMultiSkillToHomes(t *testing.T) { + names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"} + src := writeMultiSkillSource(t, names) + + got, err := listMultiSkillNames(src) + if err != nil { + t.Fatalf("listMultiSkillNames err: %v", err) + } + if len(got) != len(names) { + t.Fatalf("expected %d skills, got %d (%v)", len(names), len(got), got) + } + + dst1 := filepath.Join(t.TempDir(), ".claude", "skills") + dst2 := filepath.Join(t.TempDir(), ".cursor", "skills") + + var stdout, stderr bytes.Buffer + installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr) + if err != nil { + t.Fatalf("installMultiSkillToHomes err: %v", err) + } + if installed != len(names)*2 || skipped != 0 { + t.Fatalf("expected installed=%d skipped=0, got %d/%d (stderr=%q)", len(names)*2, installed, skipped, stderr.String()) + } + for _, d := range []string{dst1, dst2} { + for _, n := range names { + sub := filepath.Join(d, n) + if _, err := os.Stat(filepath.Join(sub, "SKILL.md")); err != nil { + t.Fatalf("missing %s/SKILL.md: %v", sub, err) + } + if _, err := os.Stat(filepath.Join(sub, "references", n+".md")); err != nil { + t.Fatalf("missing %s/references/%s.md: %v", sub, n, err) + } + } + // dws/ should NOT exist (multi mode is pure siblings) + if _, err := os.Stat(filepath.Join(d, "dws")); err == nil { + t.Fatalf("unexpected dws/ subdir in multi-mode install at %s", d) + } + } +} + +func TestSkillSetupMutualExclusion(t *testing.T) { + names := []string{"dingtalk-aitable", "dingtalk-calendar"} + src := writeMultiSkillSource(t, names) + + // Simulate a pre-existing mono install under /dws/ + agentHome := filepath.Join(t.TempDir(), ".claude", "skills") + monoLeftover := filepath.Join(agentHome, "dws") + if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono"), 0o644); err != nil { + t.Fatal(err) + } + + // Sanity: leftover exists before + if _, err := os.Stat(monoLeftover); err != nil { + t.Fatalf("setup: mono leftover should exist before, err=%v", err) + } + + // Confirm mutualExclusionVictims sees the leftover + victims := mutualExclusionVictims(agentHome, skillSetupModeMulti) + if len(victims) != 1 || victims[0] != monoLeftover { + t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims) + } + + var stdout, stderr bytes.Buffer + installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr) + if err != nil { + t.Fatalf("install err: %v (stderr=%s)", err, stderr.String()) + } + if installed != len(names) || skipped != 0 { + t.Fatalf("expected installed=%d skipped=0, got %d/%d", len(names), installed, skipped) + } + + // mono leftover should be gone + if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) { + t.Fatalf("expected mono leftover removed, stat err=%v", err) + } + // multi skills should be in place + for _, n := range names { + if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil { + t.Fatalf("missing %s/%s/SKILL.md: %v", agentHome, n, err) + } + } + // the cleanup line should appear in stdout (best-effort observability) + if !strings.Contains(stdout.String(), "已清理对面模式残留") { + t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String()) + } + + // Now test the reverse: pre-existing multi → installing mono cleans dingtalk-* + monoSrc := t.TempDir() + if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil { + t.Fatal(err) + } + monoDest := filepath.Join(agentHome, "dws") + stdout.Reset() + stderr.Reset() + installed2, skipped2, err := installSkillToHomes(monoSrc, []string{monoDest}, &stdout, &stderr) + if err != nil { + t.Fatalf("mono install err: %v", err) + } + if installed2 != 1 || skipped2 != 0 { + t.Fatalf("expected mono installed=1 skipped=0, got %d/%d", installed2, skipped2) + } + // All dingtalk-* siblings should be gone after mono install + for _, n := range names { + if _, err := os.Stat(filepath.Join(agentHome, n)); !os.IsNotExist(err) { + t.Fatalf("expected %s removed by mutual exclusion, stat err=%v", n, err) + } + } + if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil { + t.Fatalf("mono SKILL.md missing: %v", err) + } + if !strings.Contains(stdout.String(), "已清理对面模式残留") { + t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String()) + } +} + +// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level +// cache populated by install.sh / install.ps1 / npm install.js is part of the +// fallback candidate list, so `dws skill setup` can find a source on a fresh +// machine without --source. +func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir error = %v", err) + } + + for _, subdir := range []string{"mono", "multi"} { + got := skillSourceCandidates("", subdir) + want := filepath.Join(home, ".dws", "skills", subdir) + found := false + for _, c := range got { + if c == want { + found = true + break + } + } + if !found { + t.Fatalf("skillSourceCandidates(%q) missing %q; got %v", subdir, want, got) + } + } +} + +// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no +// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver +// successfully discovers ~/.dws/skills/multi/ as the source. +func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("DWS_SKILL_SOURCE", "") + + cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi") + for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} { + if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil { + t.Fatal(err) + } + } + + // Run resolver from a tempdir that has no skills/ on disk, simulating a + // fresh user machine without a source checkout. + scratch := t.TempDir() + t.Chdir(scratch) + + got, err := resolveSkillSetupSource("", skillSetupModeMulti) + if err != nil { + t.Fatalf("expected user-cache fallback to succeed, got err=%v", err) + } + if got != cacheRoot { + t.Fatalf("expected %s, got %s", cacheRoot, got) + } +} + +func TestNormalizeMultiSkillName(t *testing.T) { + cases := []struct { + in, want string + }{ + {"aitable", "dingtalk-aitable"}, + {"dingtalk-aitable", "dingtalk-aitable"}, + {" Calendar ", "dingtalk-calendar"}, + {"DINGTALK-DOC", "dingtalk-doc"}, + {"", ""}, + {" ", ""}, + } + for _, c := range cases { + if got := normalizeMultiSkillName(c.in); got != c.want { + t.Errorf("normalizeMultiSkillName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestFilterMultiSkillNames(t *testing.T) { + all := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", "dingtalk-live"} + + t.Run("no filter returns all", func(t *testing.T) { + got, err := filterMultiSkillNames(all, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != len(all) { + t.Fatalf("expected %d, got %v", len(all), got) + } + }) + + t.Run("include short names", func(t *testing.T) { + got, err := filterMultiSkillNames(all, []string{"aitable", "calendar"}, nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" { + t.Fatalf("got %v", got) + } + }) + + t.Run("include full names", func(t *testing.T) { + got, err := filterMultiSkillNames(all, []string{"dingtalk-doc"}, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "dingtalk-doc" { + t.Fatalf("got %v", got) + } + }) + + t.Run("include dedups", func(t *testing.T) { + got, err := filterMultiSkillNames(all, []string{"aitable", "dingtalk-aitable", "AITABLE"}, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "dingtalk-aitable" { + t.Fatalf("got %v", got) + } + }) + + t.Run("include unknown errors with available list", func(t *testing.T) { + _, err := filterMultiSkillNames(all, []string{"aitable", "bogus"}, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "bogus") { + t.Errorf("error should mention bad name, got: %s", msg) + } + if !strings.Contains(msg, "dingtalk-calendar") { + t.Errorf("error should list available names, got: %s", msg) + } + }) + + t.Run("exclude short names", func(t *testing.T) { + got, err := filterMultiSkillNames(all, nil, []string{"live", "doc"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" { + t.Fatalf("got %v", got) + } + }) + + t.Run("exclude unknown errors", func(t *testing.T) { + _, err := filterMultiSkillNames(all, nil, []string{"bogus"}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("exclude all errors", func(t *testing.T) { + _, err := filterMultiSkillNames(all, nil, []string{"aitable", "calendar", "doc", "live"}) + if err == nil { + t.Fatal("expected error when exclude drops everything") + } + if !strings.Contains(err.Error(), "全部") { + t.Errorf("expected 全部 in error, got: %s", err.Error()) + } + }) + + t.Run("include + exclude mutually exclusive", func(t *testing.T) { + _, err := filterMultiSkillNames(all, []string{"aitable"}, []string{"doc"}) + if err == nil { + t.Fatal("expected error when both given") + } + }) +} + +// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of +// `dws skill setup --mode multi -s aitable`: installing a subset must NOT +// touch already-installed dingtalk-* siblings (additive semantics, matches +// lark-cli `npx skills add -s lark-calendar`). +func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) { + src := writeMultiSkillSource(t, []string{ + "dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", + }) + agentHome := filepath.Join(t.TempDir(), ".claude", "skills") + + // Pretend the user already installed two dingtalk-* skills earlier. + preExisting := []string{"dingtalk-chat", "dingtalk-todo"} + for _, n := range preExisting { + dir := filepath.Join(agentHome, n) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil { + t.Fatal(err) + } + } + + // User now runs `... --mode multi -s aitable -s calendar`. + filtered, err := filterMultiSkillNames( + []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}, + []string{"aitable", "calendar"}, + nil, + ) + if err != nil { + t.Fatalf("filter err: %v", err) + } + + var stdout, stderr bytes.Buffer + installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr) + if err != nil { + t.Fatalf("install err: %v (stderr=%s)", err, stderr.String()) + } + if installed != 2 || skipped != 0 { + t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped) + } + + // Asked-for skills should be in place. + for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} { + if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil { + t.Errorf("missing newly-installed %s: %v", n, err) + } + } + // Unselected source skill must NOT be installed. + if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-doc")); !os.IsNotExist(err) { + t.Errorf("dingtalk-doc was not requested but appeared (stat err=%v)", err) + } + // Pre-existing sibling skills must be UNTOUCHED — additive semantics. + for _, n := range preExisting { + body, err := os.ReadFile(filepath.Join(agentHome, n, "SKILL.md")) + if err != nil { + t.Errorf("pre-existing %s was wiped (err=%v)", n, err) + continue + } + if !strings.HasPrefix(string(body), "OLD ") { + t.Errorf("pre-existing %s content changed: got %q", n, string(body)) + } + } +} + +// TestRunSkillSetupRejectsSkillFlagInMonoMode verifies that the new +// -s/--skill and -x/--exclude flags are gated on --mode multi. +func TestRunSkillSetupRejectsSkillFlagInMonoMode(t *testing.T) { + cmd := newSkillSetupCommand() + cmd.SetArgs([]string{"--mode", "mono", "--yes", "--skill", "aitable"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --skill in mono mode") + } + if !strings.Contains(err.Error(), "multi") { + t.Fatalf("error should mention multi gating, got: %v", err) + } +} + +func TestResolveSkillSetupSourceMultiFinds(t *testing.T) { + tmp := t.TempDir() + multiDir := filepath.Join(tmp, "skills", "multi") + for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} { + if err := os.MkdirAll(filepath.Join(multiDir, n), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(multiDir, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil { + t.Fatal(err) + } + } + + got, err := resolveSkillSetupSource(tmp, skillSetupModeMulti) + if err != nil { + t.Fatalf("expected to find multi source, got err=%v", err) + } + if got != multiDir { + t.Fatalf("expected %s, got %s", multiDir, got) + } +} diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index e5077eee..745a498f 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -18,6 +18,7 @@ REPO="DingTalk-Real-AI/dingtalk-workspace-cli" VERSION="${DWS_VERSION:-latest}" SKILL_NAME="dws" ROOT="${DWS_SKILLS_ROOT:-$PWD}" +DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}" # ── Helpers ────────────────────────────────────────────────────────────────── @@ -170,8 +171,11 @@ main() { curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" + # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. SKILL_SRC="$TMPDIR_WORK/extracted" - if [ -f "$TMPDIR_WORK/extracted/${SKILL_NAME}/SKILL.md" ]; then + if [ -d "$TMPDIR_WORK/extracted/mono" ] && [ -f "$TMPDIR_WORK/extracted/mono/SKILL.md" ]; then + SKILL_SRC="$TMPDIR_WORK/extracted/mono" + elif [ -f "$TMPDIR_WORK/extracted/${SKILL_NAME}/SKILL.md" ]; then SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}" fi @@ -184,6 +188,23 @@ main() { printf ' Installing under root: %s\n' "$ROOT" install_skills_to_root "$SKILL_SRC" "$ROOT" + # Cache multi/ (and a mono copy) under ~/.dws/skills so that subsequent + # `dws skill setup --mode multi|mono` invocations can find a source. + if [ -d "$TMPDIR_WORK/extracted/multi" ]; then + cache_dir="${DWS_CACHE_ROOT}/skills/multi" + rm -rf "$cache_dir" + mkdir -p "$cache_dir" + cp -R "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || \ + cp -r "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || true + file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')" + printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count" + fi + mono_cache="${DWS_CACHE_ROOT}/skills/mono" + rm -rf "$mono_cache" + mkdir -p "$mono_cache" + cp -R "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || \ + cp -r "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || true + printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 8c84269b..5d1cbb1c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -17,6 +17,7 @@ # DWS_ARCH — architecture override (amd64 or arm64) # DWS_NO_SKILLS — set to 1 to skip skills install # DWS_SKILLS_ONLY — set to 1 to install only skills +# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono) # # Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match). @@ -29,6 +30,7 @@ $Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" } $NoSkills = $env:DWS_NO_SKILLS -eq "1" $SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1" $SkillName = "dws" +$SkillMode = "" # Agent skill base directories (same order as build/npm/install.js AGENT_DIRS). $AgentDirs = @( @@ -219,6 +221,67 @@ function Write-Banner { Write-Host "" } +# ── Skill Mode Resolution ──────────────────────────────────────────────────── +# +# Priority (highest first): +# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive) +# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono) +# 3. Fallback: mono (non-TTY without env var, e.g. irm | iex) +function Resolve-SkillMode { + if ($env:DWS_SKILL_MODE) { + $normalized = $env:DWS_SKILL_MODE.ToLower() + if ($normalized -eq "mono" -or $normalized -eq "multi") { + $script:SkillMode = $normalized + Write-Say "Skill mode: $SkillMode (from DWS_SKILL_MODE)" + return + } + Write-Err "Invalid DWS_SKILL_MODE='$($env:DWS_SKILL_MODE)'. Use 'mono' or 'multi'." + } + + $isInteractive = $false + try { + $isInteractive = ([Console]::IsInputRedirected -eq $false) -and ([Console]::IsOutputRedirected -eq $false) + } catch { + $isInteractive = $false + } + + if ($isInteractive) { + Write-Host "" + Write-Say "Select skill installation mode:" + Write-Say " 1) mono — install one bundled dws skill (stable / recommended)" + Write-Say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)" + Write-Say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change" + $choice = Read-Host " Choice [1]" + switch ($choice) { + "" { $script:SkillMode = "mono" } + "1" { $script:SkillMode = "mono" } + "mono" { $script:SkillMode = "mono" } + "2" { $script:SkillMode = "multi" } + "multi" { $script:SkillMode = "multi" } + default { + Write-Say "Unrecognized choice '$choice', defaulting to mono." + $script:SkillMode = "mono" + } + } + Write-Say "Skill mode: $SkillMode" + return + } + + $script:SkillMode = "mono" +} + +function Write-MultiModeNotice { + Write-Say "" + Write-Say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped." + Write-Say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier," + Write-Say " but interface, naming and cross-skill references may change in future releases." + Write-Say " For production / shared environments, use mono mode (--mode mono)." + Write-Say "" + Write-Say " To install split skills, run:" + Write-Say " $BinName skill setup --mode multi" + Write-Say " (One skill per product family; requires the dws binary installed above.)" +} + # ── Install Binary ─────────────────────────────────────────────────────────── function Install-Binary { @@ -297,7 +360,8 @@ function Install-Binary { function Install-SkillsLocal { param([string]$Root) - $skillSrc = Join-Path $Root "skills" + $skillSrc = Join-Path (Join-Path $Root "skills") "mono" + $multiSrc = Join-Path (Join-Path $Root "skills") "multi" if (!(Test-Path $skillSrc)) { Write-Say "⚠️ Local skills directory not found: $skillSrc" @@ -309,6 +373,41 @@ function Install-SkillsLocal { Write-Say "📦 Installing agent skills from local source: $skillSrc" Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME + + if (Test-Path $multiSrc) { + Cache-MultiSkills -Source $multiSrc + } + Cache-MonoSkills -Source $skillSrc +} + +# Cache-MultiSkills mirrors install.sh cache_multi_skills: copies the multi/ +# tree to ~/.dws/skills/multi/ so `dws skill setup --mode multi` can find a +# source without needing the source checkout or a re-download. +function Cache-MultiSkills { + param([string]$Source) + + if (!(Test-Path $Source)) { return } + + $cacheDir = Join-Path $HOME ".dws\skills\multi" + if (Test-Path $cacheDir) { + Remove-Item -Path $cacheDir -Recurse -Force + } + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $count = Copy-DirRecursive -Source $Source -Destination $cacheDir + Write-Say "✅ Cached multi skills → $cacheDir ($count files)" +} + +function Cache-MonoSkills { + param([string]$Source) + + if (!(Test-Path $Source)) { return } + + $cacheDir = Join-Path $HOME ".dws\skills\mono" + if (Test-Path $cacheDir) { + Remove-Item -Path $cacheDir -Recurse -Force + } + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + Copy-DirRecursive -Source $Source -Destination $cacheDir | Out-Null } function Install-SkillsToHomes { @@ -407,8 +506,12 @@ function Install-Skills { $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force + # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. $skillSrc = $extractRoot - if (Test-Path (Join-Path $extractRoot "$SkillName\SKILL.md")) { + $monoRoot = Join-Path $extractRoot "mono" + if ((Test-Path $monoRoot) -and (Test-Path (Join-Path $monoRoot "SKILL.md"))) { + $skillSrc = $monoRoot + } elseif (Test-Path (Join-Path $extractRoot "$SkillName\SKILL.md")) { $skillSrc = Join-Path $extractRoot $SkillName } @@ -424,6 +527,14 @@ function Install-Skills { } Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME + + # Cache the multi/ tree (and a mono copy) under ~/.dws/skills so that + # subsequent `dws skill setup --mode multi|mono` can find a source. + $multiRoot = Join-Path $extractRoot "multi" + if (Test-Path $multiRoot) { + Cache-MultiSkills -Source $multiRoot + } + Cache-MonoSkills -Source $skillSrc } finally { Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue } @@ -435,18 +546,34 @@ $SourceRoot = Resolve-SourceRoot Write-Banner +if (!$NoSkills) { + Resolve-SkillMode +} + if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) { Install-BinaryFromSource -Root $SourceRoot if (!$NoSkills) { - Install-SkillsLocal -Root $SourceRoot + if ($SkillMode -eq "multi") { + Write-MultiModeNotice + } else { + Install-SkillsLocal -Root $SourceRoot + } } } elseif ($SkillsOnly) { - Install-Skills + if ($SkillMode -eq "multi") { + Write-MultiModeNotice + } else { + Install-Skills + } } elseif ($NoSkills) { Install-Binary } else { Install-Binary - Install-Skills + if ($SkillMode -eq "multi") { + Write-MultiModeNotice + } else { + Install-Skills + } } Write-Host "" diff --git a/scripts/install.sh b/scripts/install.sh index 1c9b2f47..87512e95 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,6 +14,7 @@ # DWS_VERSION — version to install (default: latest) # DWS_NO_SKILLS — set to 1 to skip skills install # DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary) +# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono) # # Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match). @@ -27,6 +28,7 @@ VERSION="${DWS_VERSION:-latest}" NO_SKILLS="${DWS_NO_SKILLS:-0}" SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}" SKILL_NAME="dws" +SKILL_MODE="" # ── Helpers ────────────────────────────────────────────────────────────────── @@ -137,6 +139,64 @@ print_banner() { printf '\n' } +# ── Skill Mode Resolution ──────────────────────────────────────────────────── +# +# Priority (highest first): +# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive) +# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono) +# 3. Fallback: mono (non-TTY without env var, e.g. curl | sh) +resolve_skill_mode() { + if [ -n "${DWS_SKILL_MODE:-}" ]; then + raw="$DWS_SKILL_MODE" + # Lower-case without bash-specific ${var,,}; tr is POSIX. + normalized="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$normalized" in + mono|multi) + SKILL_MODE="$normalized" + say "Skill mode: ${SKILL_MODE} (from DWS_SKILL_MODE)" + return 0 + ;; + *) + err "Invalid DWS_SKILL_MODE='${raw}'. Use 'mono' or 'multi'." + ;; + esac + fi + + if [ -t 0 ] && [ -t 1 ]; then + printf '\n' + say "Select skill installation mode:" + say " 1) mono — install one bundled dws skill (stable / recommended)" + say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)" + say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change" + printf ' Choice [1]: ' + read choice || choice="" + case "$choice" in + ""|1|mono) SKILL_MODE="mono" ;; + 2|multi) SKILL_MODE="multi" ;; + *) + say "Unrecognized choice '${choice}', defaulting to mono." + SKILL_MODE="mono" + ;; + esac + say "Skill mode: ${SKILL_MODE}" + return 0 + fi + + SKILL_MODE="mono" +} + +print_multi_mode_notice() { + say "" + say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped." + say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier," + say " but interface, naming and cross-skill references may change in future releases." + say " For production / shared environments, use mono mode (--mode mono)." + say "" + say " To install split skills, run:" + say " ${BIN_NAME} skill setup --mode multi" + say " (One skill per product family; requires the dws binary installed above.)" +} + install_binary_from_source() { root="$1" @@ -166,7 +226,8 @@ install_binary_from_source() { install_skills_local() { root="$1" - skill_src="${root}/skills" + skill_src="${root}/skills/mono" + multi_src="${root}/skills/multi" if [ ! -d "$skill_src" ]; then say "⚠️ Local skills directory not found: ${skill_src}" @@ -179,9 +240,57 @@ install_skills_local() { install_skills_to_homes "$skill_src" + # Cache multi source for later `dws skill setup --mode multi`. + if [ -d "$multi_src" ]; then + cache_multi_skills "$multi_src" + fi + + # Also cache a mono copy so `dws skill setup --mode mono` has a fallback + # under ~/.dws/skills/mono when invoked without --source. + cache_mono_skills "$skill_src" + return 0 } +# cache_multi_skills copies the multi/ tree (per-product skills) into +# ~/.dws/skills/multi/ so that `dws skill setup --mode multi` can find a +# source without needing the source checkout or a re-download. +cache_multi_skills() { + src="$1" + cache_dir="${HOME}/.dws/skills/multi" + + if [ ! -d "$src" ]; then + return 0 + fi + + rm -rf "$cache_dir" + mkdir -p "$cache_dir" + cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true + + file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')" + case "$cache_dir" in + "$HOME"/*) label="~/${cache_dir#$HOME/}" ;; + *) label="$cache_dir" ;; + esac + say "✅ Cached multi skills → ${label} (${file_count} files)" +} + +# cache_mono_skills mirrors cache_multi_skills for the mono tree. Keeping the +# two modes symmetrical means `dws skill setup` can fall back to ~/.dws/skills +# regardless of which mode the user picks later. +cache_mono_skills() { + src="$1" + cache_dir="${HOME}/.dws/skills/mono" + + if [ ! -d "$src" ]; then + return 0 + fi + + rm -rf "$cache_dir" + mkdir -p "$cache_dir" + cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true +} + # Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes). install_skills_to_homes() { skill_src="$1" @@ -403,8 +512,14 @@ install_skills() { err "Cannot extract release skill archive and no local source checkout found." fi + # New release layout puts mono content both at the zip root (for backward + # compatibility with older installers) and under ./mono/, with multi/ as a + # sibling. Prefer ./mono/ when present so we never miss SKILL.md, then fall + # back to the legacy nested $SKILL_NAME/ shape, then the zip root. skill_src="$extract_root" - if [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then + if [ -d "$extract_root/mono" ] && [ -f "$extract_root/mono/SKILL.md" ]; then + skill_src="$extract_root/mono" + elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then skill_src="$extract_root/$SKILL_NAME" fi if [ ! -f "$skill_src/SKILL.md" ]; then @@ -422,6 +537,15 @@ install_skills() { install_skills_to_homes "$skill_src" + # Cache the multi tree (if present in the release asset) so a later + # `dws skill setup --mode multi` can find a source without re-downloading. + if [ -d "$extract_root/multi" ]; then + cache_multi_skills "$extract_root/multi" + fi + + # And cache mono too for symmetry with --mode mono fallbacks. + cache_mono_skills "$skill_src" + rm -rf "$tmpdir_skills" } @@ -435,23 +559,40 @@ main() { print_banner + # Resolve skill mode only when we are actually going to touch skills. + if [ "$NO_SKILLS" != "1" ]; then + resolve_skill_mode + fi + if [ -n "$source_root" ]; then install_binary_from_source "$source_root" if [ "$NO_SKILLS" != "1" ]; then - install_skills_local "$source_root" + if [ "$SKILL_MODE" = "multi" ]; then + print_multi_mode_notice + else + install_skills_local "$source_root" + fi fi elif [ "$SKILLS_ONLY" = "1" ]; then - local_root="$(resolve_source_root || true)" - if [ -n "$local_root" ]; then - install_skills_local "$local_root" + if [ "$SKILL_MODE" = "multi" ]; then + print_multi_mode_notice else - install_skills + local_root="$(resolve_source_root || true)" + if [ -n "$local_root" ]; then + install_skills_local "$local_root" + else + install_skills + fi fi elif [ "$NO_SKILLS" = "1" ]; then install_binary else install_binary - install_skills + if [ "$SKILL_MODE" = "multi" ]; then + print_multi_mode_notice + else + install_skills + fi fi printf '\n' diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh index 433466f9..798047d5 100755 --- a/scripts/release/post-goreleaser.sh +++ b/scripts/release/post-goreleaser.sh @@ -168,10 +168,31 @@ stage_homebrew_formula() { create_skills_zip() { skills_zip="$DIST_DIR/dws-skills.zip" rm -f "$skills_zip" + + staging="$(mktemp -d)" + # Layout inside dws-skills.zip: + # /SKILL.md + references/ + scripts/ ← copy of mono/, kept at root + # for backward compatibility + # with older install scripts + # that look for SKILL.md at + # the zip root. + # /mono/ ← explicit mono source tree + # /multi/ ← multi source tree (one + # subdir per product skill) + cp -R "$ROOT/skills/mono/." "$staging/" + mkdir -p "$staging/mono" + cp -R "$ROOT/skills/mono/." "$staging/mono/" + mkdir -p "$staging/multi" + if [ -d "$ROOT/skills/multi" ]; then + cp -R "$ROOT/skills/multi/." "$staging/multi/" + fi + ( - cd "$ROOT/skills" + cd "$staging" env -u LC_ALL -u LC_CTYPE LANG=C LC_ALL=C LC_CTYPE=C zip -qr "$skills_zip" . ) + + rm -rf "$staging" } # ---------- darwin ad-hoc signing ---------- diff --git a/skills/LICENSE b/skills/mono/LICENSE similarity index 100% rename from skills/LICENSE rename to skills/mono/LICENSE diff --git a/skills/NOTICE b/skills/mono/NOTICE similarity index 100% rename from skills/NOTICE rename to skills/mono/NOTICE diff --git a/skills/SKILL.md b/skills/mono/SKILL.md similarity index 96% rename from skills/SKILL.md rename to skills/mono/SKILL.md index 3a323976..43f33ac8 100644 --- a/skills/SKILL.md +++ b/skills/mono/SKILL.md @@ -8,6 +8,9 @@ cli_version: ">=1.0.15" 通过 `dws` 命令管理钉钉产品能力。 + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + ## 严格禁止 (NEVER DO) - 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器) - 不要编造 UUID、ID 等标识符,必须从命令返回中提取 diff --git a/skills/references/error-codes.md b/skills/mono/references/error-codes.md similarity index 100% rename from skills/references/error-codes.md rename to skills/mono/references/error-codes.md diff --git a/skills/references/field-rules.md b/skills/mono/references/field-rules.md similarity index 100% rename from skills/references/field-rules.md rename to skills/mono/references/field-rules.md diff --git a/skills/references/global-reference.md b/skills/mono/references/global-reference.md similarity index 100% rename from skills/references/global-reference.md rename to skills/mono/references/global-reference.md diff --git a/skills/references/intent-guide.md b/skills/mono/references/intent-guide.md similarity index 100% rename from skills/references/intent-guide.md rename to skills/mono/references/intent-guide.md diff --git a/skills/references/products/aitable.md b/skills/mono/references/products/aitable.md similarity index 100% rename from skills/references/products/aitable.md rename to skills/mono/references/products/aitable.md diff --git a/skills/references/products/attendance.md b/skills/mono/references/products/attendance.md similarity index 100% rename from skills/references/products/attendance.md rename to skills/mono/references/products/attendance.md diff --git a/skills/references/products/calendar.md b/skills/mono/references/products/calendar.md similarity index 100% rename from skills/references/products/calendar.md rename to skills/mono/references/products/calendar.md diff --git a/skills/mono/references/products/chat-emoji-list.md b/skills/mono/references/products/chat-emoji-list.md new file mode 100644 index 00000000..338710fd --- /dev/null +++ b/skills/mono/references/products/chat-emoji-list.md @@ -0,0 +1,209 @@ +# 钉钉默认表情列表(emoji 回应可用) + +> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。 +> +> 使用规则: +> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji ` 贴 emoji 回应 +> - 用户描述的表情未命中下表 → 先 `chat message create-text-emotion` 创建文字表情获取 emotionId,再 `chat message add-text-emotion` 贴文字表情回应 + +| # | emotionId | name | en_US | +|---|-----------|------|-------| +| 1 | emotion_001 | 微笑 | Smile | +| 2 | emotion_099 | 可爱 | Lovely | +| 3 | emotion_002 | 憨笑 | Wow | +| 4 | emotion_003 | 色 | Yum | +| 5 | emotion_004 | 发呆 | Dazed | +| 6 | emotion_005 | 老板 | Boss | +| 7 | emotion_036 | 傻笑 | Oops | +| 8 | emotion_006 | 流泪 | Sob | +| 9 | emotion_007 | 害羞 | Shy | +| 10 | emotion_008 | 闭嘴 | Silence | +| 11 | emotion_009 | 睡 | Sleepy | +| 12 | emotion_010 | 大哭 | Cry | +| 13 | emotion_011 | 尴尬 | Awkward | +| 14 | emotion_080 | 感谢 | Thanks | +| 15 | emotion_204 | 拒绝 | SayNo | +| 16 | emotion_078 | 赞 | Like | +| 17 | emotion_024 | 鼓掌 | Clap | +| 18 | emotion_105 | 打招呼 | Hi | +| 19 | emotion_159 | 666 | 666 | +| 20 | emotion_079 | 抱拳 | Salute | +| 21 | emotion_025 | 握手 | Shake | +| 22 | emotion_023 | OK | OK | +| 23 | emotion_033 | 胜利 | Peace | +| 24 | emotion_142 | 向左 | Left | +| 25 | emotion_143 | 向右 | Right | +| 26 | emotion_144 | 向上 | Up | +| 27 | emotion_145 | 向下 | Down | +| 28 | emotion_185 | 来呀 | Come | +| 29 | emotion_155 | 一点点 | ALittle | +| 30 | emotion_179 | 捏住 | Pinch | +| 31 | emotion_140 | 比心 | FingerHeart | +| 32 | emotion_106 | 送花花 | Flower | +| 33 | emotion_178 | 加油干 | MakeEffort | +| 34 | emotion_013 | 调皮 | Tongueout | +| 35 | emotion_014 | 大笑 | Laugh | +| 36 | emotion_015 | 惊讶 | Scowl | +| 37 | emotion_016 | 流汗 | Sweat | +| 38 | emotion_017 | 奋斗 | Fight | +| 39 | emotion_018 | 口罩 | Mask | +| 40 | emotion_019 | 生病 | Sick | +| 41 | emotion_020 | 吐 | Barf | +| 42 | emotion_021 | 难过 | Bummed | +| 43 | emotion_022 | 抓狂 | Crazy | +| 44 | emotion_026 | 右哼哼 | Humph | +| 45 | emotion_027 | 太阳 | Sunny | +| 46 | emotion_028 | 月亮 | Moon | +| 47 | emotion_029 | 强 | Thumbsup | +| 48 | emotion_030 | 弱 | Thumbsdown | +| 49 | emotion_031 | 彩带 | Tada | +| 50 | emotion_032 | 蛋糕 | Cake | +| 51 | emotion_034 | 骷髅 | Skull | +| 52 | emotion_035 | 撇嘴 | Pout | +| 53 | emotion_037 | 鄙视 | Dislike | +| 54 | emotion_038 | 嘘 | Shhh | +| 55 | emotion_040 | 思考 | Hmm… | +| 56 | emotion_041 | 亲亲 | Kiss | +| 57 | emotion_042 | 无奈 | Disappointed | +| 58 | emotion_043 | 感冒 | Pollution | +| 59 | emotion_044 | 对不起 | Sorry | +| 60 | emotion_045 | 再见 | Wave | +| 61 | emotion_046 | 投降 | GiveUp | +| 62 | emotion_047 | 哼 | Grumpy | +| 63 | emotion_048 | 欠扁 | FaceSlap | +| 64 | emotion_049 | 拜托 | Please | +| 65 | emotion_050 | 可怜 | Aww… | +| 66 | emotion_051 | 舒服 | Relax | +| 67 | emotion_052 | 爱意 | Romantic | +| 68 | emotion_054 | 财迷 | MoneyMoney | +| 69 | emotion_055 | 迷惑 | Puzzled | +| 70 | emotion_056 | 委屈 | Worried | +| 71 | emotion_057 | 灵感 | Idea | +| 72 | emotion_058 | 天使 | Angel | +| 73 | emotion_059 | 鬼脸 | SillyFace | +| 74 | emotion_060 | 凄凉 | Phew | +| 75 | emotion_061 | 郁闷 | Tired | +| 76 | emotion_063 | 坏笑 | Trick | +| 77 | emotion_064 | 算账 | SoMuch | +| 78 | emotion_206 | PK | PK | +| 79 | emotion_066 | 忍者 | Sneaky | +| 80 | emotion_039 | 衰 | Grr | +| 81 | emotion_067 | 炸弹 | Uh-Oh | +| 82 | emotion_081 | 笑哭 | LaughAndCry | +| 83 | emotion_082 | 嘿嘿 | Smirk | +| 84 | emotion_083 | 捂脸哭 | Facepalm | +| 85 | emotion_084 | 抠鼻 | NosePick | +| 86 | emotion_085 | 流鼻血 | BloodyNose | +| 87 | emotion_090 | 呲牙 | Grin | +| 88 | emotion_091 | 吃瓜 | EatingMelon | +| 89 | emotion_092 | 彩虹 | Rainbow | +| 90 | emotion_098 | 耶 | Yeah | +| 91 | emotion_012 | 发怒 | Steamed | +| 92 | emotion_100 | 捂眼睛 | CannotLook | +| 93 | emotion_101 | 推眼镜 | PushGlasses | +| 94 | emotion_102 | 暗中观察 | Peep | +| 95 | emotion_103 | 脑暴 | Brainstorming | +| 96 | emotion_112 | 冷笑 | Distressed | +| 97 | emotion_208 | 热 | HotFace | +| 98 | emotion_113 | 开心 | Happy | +| 99 | emotion_114 | 惊喜 | Surprised | +| 100 | emotion_115 | 回头 | LookBack | +| 101 | emotion_116 | 白眼 | RollEyes | +| 102 | emotion_117 | 一团乱麻 | Overwhelmed | +| 103 | emotion_149 | 黑眼圈 | DarkCircle | +| 104 | emotion_225 | 裂开 | Broken | +| 105 | emotion_156 | 恭喜 | Congrats | +| 106 | emotion_160 | 费解 | Confuse | +| 107 | emotion_167 | 收到 | RogerThat | +| 108 | emotion_186 | 快来 | ComeOn | +| 109 | emotion_086 | 敲打 | Hammer | +| 110 | emotion_176 | 捧脸 | HoldFace | +| 111 | emotion_194 | Get | Get | +| 112 | emotion_207 | 客服 | CustomerService | +| 113 | emotion_210 | AR | AR | +| 114 | emotion_221 | 小蜜蜂 | Bee | +| 115 | emotion_211 | 虎虎生威 | MajesticTiger | +| 116 | emotion_222 | 兔飞猛进 | Rabbit | +| 117 | emotion_226 | 龙头老大 | Dragon Face | +| 118 | emotion_236 | 蛇来运转 | Good luck | +| 119 | emotion_238 | 马上来财 | Wealth is coming | +| 120 | emotion_093 | 专注 | Concentrate | +| 121 | emotion_166 | 忙疯了 | CrazyBusy | +| 122 | emotion_187 | 等一等 | Wait | +| 123 | emotion_227 | 一脸苦笑 | Wry Smile | +| 124 | emotion_228 | 王之蔑视 | Unamused | +| 125 | emotion_229 | 洪荒之力 | Amazing | +| 126 | emotion_234 | 向左看 | Thinking | +| 127 | emotion_235 | 向右看 | Pondering | +| 128 | emotion_230 | YYDS | YYDS | +| 129 | emotion_231 | 这边请 | ThisWayPlease | +| 130 | emotion_232 | 弹射下班 | OffDuty | +| 131 | emotion_233 | 退退退 | Back | +| 132 | emotion_188 | 在吗 | Hello? | +| 133 | emotion_189 | 让人头大 | Hard | +| 134 | emotion_089 | 摊手 | Smugshrug | +| 135 | emotion_088 | 抱抱 | Hug | +| 136 | emotion_158 | 举手 | RaiseHand | +| 137 | emotion_177 | 开车 | Driving | +| 138 | emotion_191 | 抱大腿 | Follow | +| 139 | emotion_087 | 跪了 | YouWin | +| 140 | emotion_162 | 鞠躬 | Bow | +| 141 | emotion_180 | 选我 | PickMe | +| 142 | emotion_209 | 元气满满 | FullOfVitality | +| 143 | emotion_168 | 会议 | Meeting | +| 144 | emotion_095 | 猫咪 | Kitty | +| 145 | emotion_094 | 二哈 | Doggy | +| 146 | emotion_097 | 狗子 | Puppy | +| 147 | emotion_111 | 三多 | SanDuo | +| 148 | emotion_153 | 承让 | LetMeWin | +| 149 | emotion_154 | 撒花 | Celebration | +| 150 | emotion_070 | 礼物 | Present | +| 151 | emotion_104 | 生日快乐 | Birthday | +| 152 | emotion_071 | 爱心 | Love | +| 153 | emotion_072 | 心碎 | BrokenHeart | +| 154 | emotion_073 | 嘴唇 | Lips | +| 155 | emotion_074 | 鲜花 | Rose | +| 156 | emotion_075 | 残花 | Wilted | +| 157 | emotion_077 | 干杯 | Cheers | +| 158 | emotion_151 | 咖啡 | Coffee | +| 159 | emotion_152 | 奶茶 | MilkTea | +| 160 | emotion_202 | 茶 | Tea | +| 161 | emotion_218 | OKR | OKR | +| 162 | emotion_109 | KPI | KPI | +| 163 | emotion_108 | 100分 | 100 | +| 164 | emotion_110 | 对勾 | Check | +| 165 | emotion_192 | 打叉 | Wrong | +| 166 | emotion_174 | 气泡 | Bubble | +| 167 | emotion_157 | 加一 | PlusOne | +| 168 | emotion_193 | Done | Done | +| 169 | emotion_146 | 钉子 | Staple | +| 170 | emotion_076 | 出差 | BusinessTrip | +| 171 | emotion_181 | 高铁 | HighSpeedTrain | +| 172 | emotion_184 | 火箭 | Rocket | +| 173 | emotion_068 | 邮件 | Mail | +| 174 | emotion_163 | 文档 | Document | +| 175 | emotion_164 | 演示 | Presentation | +| 176 | emotion_165 | 表格 | Sheet | +| 177 | emotion_213 | 废纸篓 | Wastebasket | +| 178 | emotion_237 | 手机 | MobilePhone | +| 179 | emotion_203 | 时间 | Time | +| 180 | emotion_217 | 静音 | mute | +| 181 | emotion_201 | 公文包 | Briefcase | +| 182 | emotion_214 | 地球 | Earth | +| 183 | emotion_215 | 碳减排 | CarbonReduction | +| 184 | emotion_216 | 回收标志 | RecyclingSymbol | +| 185 | emotion_205 | 幼苗 | Seedling | +| 186 | emotion_096 | 红包 | RedPacket | +| 187 | emotion_150 | 锦鲤 | LuckyDog | +| 188 | emotion_148 | 福 | Luck | +| 189 | emotion_198 | 灯笼 | Lantern | +| 190 | emotion_199 | 爆竹 | Firecrackers | +| 191 | emotion_197 | 烟花 | Fireworks | +| 192 | emotion_195 | 恭喜发财 | Prosperity | +| 193 | emotion_161 | 月饼 | MoonCake | +| 194 | emotion_173 | 鸡腿 | ChickenLeg | +| 195 | emotion_169 | 休假 | Vacation | +| 196 | emotion_175 | 火 | Hot | +| 197 | emotion_223 | 点赞 | LikeHeartAndTripleSix | +| 198 | emotion_196 | 平安健康 | Peace&Health | +| 199 | emotion_200 | 定胜 | Victory | diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md new file mode 100644 index 00000000..8729ef3b --- /dev/null +++ b/skills/mono/references/products/chat.md @@ -0,0 +1,1420 @@ +# 会话与群聊 (chat) 命令参考 + +## 命令总览 + +### group (群组管理) + +| 子命令 | 用途 | +|-------|------| +| `group create` | 创建内部群 | +| `group members list` | 查看群成员列表 | +| `group members add` | 添加群成员 | +| `group members remove` | 移除群成员(⚠️ 危险操作) | +| `group members add-bot` | 添加机器人到群 | +| `group rename` | 修改群名称 | +| `group get-by-group-id` | 按数字群号获取会话信息(群号转 openConversationId) | +| `group transfer-owner` | 转让群主 | +| `group invite-url` | 获取群邀请链接 | +| `group quit` | 退出群聊 | +| `group update-icon` | 更新群头像 | +| `group update-settings` | 更新群设置 | +| `group set-admin` | 设置/取消群管理员 | +| `group member-role` | 群自定义角色(add / list / update / remove / set-user / remove-user / query-user) | + +### message (会话消息管理) + +| 子命令 | 用途 | +|-------|------| +| `message send` | 以当前用户身份发消息(群聊或单聊) | +| `message recall-by-bot` | 撤回机器人发出的消息(开源 dws v1.0.30 不提供撤回普通用户消息的命令)| +| `message send-by-bot` | 机器人发消息(群聊或批量单聊) | +| `message recall-by-bot` | 机器人撤回消息 | +| `message send-by-webhook` | 自定义机器人 Webhook 发消息 | +| `message send-card` / `update-card` | 创建并推送流式卡片,配合更新内容 | +| `message reply` | 引用回复消息(单聊/群聊均可) | +| `message forward` | 转发单条消息(源/目标会话均支持单聊/群聊) | +| `message list` | 拉取群聊或单聊会话消息 | +| `message list-all` | 按时间范围拉取当前用户所有会话消息 | +| `message list-topic-replies` | 拉取群话题回复消息列表 | +| `message list-by-sender` | 搜索指定发送者的消息 | +| `message list-mentions` | 拉取 @我 的消息 | +| `message list-focused` | 拉取特别关注人的消息 | +| `message list-unread-conversations` | 获取未读会话列表 | +| `message list-by-ids` | 按消息 ID 批量获取消息 | +| `message search` | 按关键词搜索消息 | +| `message search-advanced` | 多维度搜索消息(推荐首选,严格超集) | +| `message query-send-status` | 查询消息发送状态(按 openTaskId) | +| `message query-read-status` | 查询消息已读/未读状态 | +| `message add-emoji` / `remove-emoji` | 给消息添加/移除 emoji 表情回应 | +| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情回应 | +| `message create-text-emotion` | 创建文字表情模板(返回 emotionId 供 add-text-emotion 使用) | + +### chat 顶级命令 + +| 子命令 | 用途 | +|-------|------| +| `search` | 根据关键词搜索群聊 | +| `search-common` | 搜索共同群 | +| `conversation-info` | 获取会话基础信息(含 newCSpaceIdIM) | +| `list-top-conversations` | 拉取置顶会话列表 | +| `set-top` | 设置/取消会话置顶 | +| `mute` | 会话消息免打扰开关 | +| `group-mute` | 群全员禁言开关 | +| `group-mute-member` | 群成员禁言开关 | +| `list-categories` | 列出用户自定义会话分类 | +| `list-conversations` | 按分类拉取会话列表 | + +### bot (机器人管理) + +| 子命令 | 用途 | +|-------|------| +| `bot search` | 搜索我创建的机器人 | + +--- + +## group create — 创建内部群 + +当前登录用户自动成为群主。 + +``` +Usage: + dws chat group create [flags] +Example: + dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 +Flags: + --users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填) + --name string 群名称 (必填) +``` + +--- + +## group members list — 查看群成员列表 + +分页查询指定群聊的成员。 + +``` +Usage: + dws chat group members list [flags] +Example: + dws chat group members list --id +Flags: + --cursor string 分页游标,首次从 0 开始 + --id string 群 ID / openconversation_id (必填) +``` + +> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。 + +--- + +## group members add — 添加群成员 + +向指定群聊添加成员,需传入群 ID 与用户 ID 列表。 + +``` +Usage: + dws chat group members add [flags] +Example: + dws chat group members add --id --users userId1,userId2 +Flags: + --id string 群 ID / openconversation_id (必填) + --users string 要添加的用户 userId 列表,逗号分隔 (必填) +``` + +--- + +## group members remove — 移除群成员 + +> ⚠️ 危险操作:执行前必须向用户确认,同意后才加 `--yes`。 + +``` +Usage: + dws chat group members remove [flags] +Example: + dws chat group members remove --id --users userId1,userId2 +Flags: + --id string 群 ID / openconversation_id (必填) + --users string 要移除的用户 userId 列表,逗号分隔 (必填) +``` + +--- + +## group members add-bot — 添加机器人到群 + +将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。 + +``` +Usage: + dws chat group members add-bot [flags] +Example: + dws chat group members add-bot --robot-code --id +Flags: + --id string 群聊 openConversationId (必填) + --robot-code string 机器人 Code (必填) +``` + +--- + +## group rename — 修改群名称 + +``` +Usage: + dws chat group rename [flags] +Example: + dws chat group rename --id --name "新群名" +Flags: + --id string 群 ID / openconversation_id (必填) + --name string 修改后的群名称 (必填) +``` + +--- + +## search — 搜索群会话 + +根据关键词搜索群聊会话,分页返回匹配的群列表。 + +``` +Usage: + dws chat search [flags] +Example: + dws chat search --query "项目冲刺" + dws chat search --query "项目冲刺" --limit 20 --cursor 0 +Flags: + --keyword string 搜索关键词 (必填,旧版别名: --query) + --limit int 每页返回数量(默认 20) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - 旧版命令使用 `--query`,新版统一改为 `--keyword`;`--query` 仍可作为别名使用 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## search-common — 搜索共同群 + +根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。 + +``` +Usage: + dws chat search-common [flags] +Example: + dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0 + dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0 + dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor +Flags: + --nicks string 要搜索的昵称列表,逗号分隔 (必填) + --match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND) + --limit int 每页返回数量(默认 20) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔" + - --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message send — 以当前用户身份发消息 + +--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。 +--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。 +--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。 + +``` +Usage: + dws chat message send [flags] [] +Example: + dws chat message send --group --title "周报" --text "请提交本周日报" + dws chat message send --user --title "提醒" --text "请查收" + dws chat message send --open-dingtalk-id --title "提醒" --text "请查收" + dws chat message send --group --title "通知" "hello" + dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" + dws chat message send --group --title "通知" --at-all "<@all> 请大家注意" + dws chat message send --group --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收" + dws chat message send --group --title "图片" --media-id + dws chat message send --open-dingtalk-id --title "图片" --media-id +Flags: + --text string 消息内容(推荐使用,也可用位置参数) + --group string 群聊 openconversation_id(群聊时必填) + --user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + --title string 消息标题(必填) + --at-all @所有人(仅群聊时生效,可选,默认 false) + --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选) + --at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选) + --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容 + --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断) + --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id) + --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用) + --file-name string 文件消息的文件名 + --file-size string 文件消息的文件大小(字节) + --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等) + +注意: + - --text 和位置参数二选一,--text 优先 + - --title 必填(群聊与单聊都必填,API 强制要求) + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2> + - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user + - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file +``` + +--- + +## message list — 拉取会话消息内容 + +拉取指定群聊或单聊的会话消息内容。 + +--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。 + +``` +Usage: + dws chat message list [flags] +Example: + # 拉取群聊中某个时间点之后的消息 + dws chat message list --group --time "2025-03-01 00:00:00" + # 拉取单聊消息(通过 userId) + dws chat message list-direct --user --time "2025-03-01 00:00:00" --limit 50 + # 拉取单聊消息(通过 openDingTalkId) + dws chat message list-direct --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 + # 拉取某个时间点之前的消息(向过去翻页) + dws chat message list --group --time "2025-03-01 00:00:00" --forward=false +Flags: + --forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true) + --group string 群聊 openconversation_id(群聊时必填) + --limit int 返回数量,不传则不限制 + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息) + --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + +注意: + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数 +``` + +### 分页翻页说明(重要) + +`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆: + +| 命令 | 翻页参数 | 翻页值来源 | 值格式 | +|------|---------|-----------|--------| +| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) | +| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) | + +**翻页步骤(message list):** + +1. **首次请求**:指定起始时间 + ```bash + dws chat message list --group --time "2025-03-01 00:00:00" --limit 50 --format json + ``` +2. **检查响应**:查看 `hasMore` 字段 + - `hasMore: false` → 没有更多消息,翻页结束 + - `hasMore: true` → 还有更多消息,继续下一步 +3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`) +4. **下一页请求**:将该 `createTime` 作为 `--time` 传入 + ```bash + dws chat message list --group --time "2025-03-01 14:30:00" --limit 50 --format json + ``` +5. 重复步骤 2-4 直到 `hasMore: false` + +> ⚠️ **常见错误**: +> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。 +> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。 + +--- + +## message list-all — 拉取指定时间范围内当前用户的所有会话消息 + +分页拉取当前登录用户在指定时间范围内的所有会话消息。 + +--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。 + +``` +Usage: + dws chat message list-all [flags] +Example: + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" +Flags: + --start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取) + +注意: + - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" + - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 + - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 + - 时间格式统一为 yyyy-MM-dd HH:mm:ss +``` + +--- + +## message list-topic-replies — 拉取群话题回复消息列表 + +查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。 + +``` +Usage: + dws chat message list-topic-replies [flags] +Example: + dws chat message list-topic-replies --group --topic-id + dws chat message list-topic-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20 +Flags: + --group string 群会话 openconversationId (必填) + --topic-id string 话题 ID,由 dws chat message list 返回 (必填) + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选) + --limit int 返回数量(默认 50) + --forward true=从老往新,false=从新往老(默认 false) +``` + +--- + +## message list-by-sender — 拉取指定发送者的消息 + +搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。 + +``` +Usage: + dws chat message list-by-sender [flags] +Example: + dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) + --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一 + - 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识 + - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-mentions — 拉取 @我 的消息 + +搜索时间范围内 @我 的消息,可选指定群聊。 + +``` +Usage: + dws chat message list-mentions [flags] +Example: + dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --group string 群聊 openconversation_id(可选,不传则查全部) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-focused — 拉取特别关注人的消息 + +拉取当前用户特别关注人的消息。 + +``` +Usage: + dws chat message list-focused [flags] +Example: + dws chat message list-focused --limit 50 + dws chat message list-focused --limit 20 --cursor +Flags: + --limit int 每页返回数量(默认 50) + --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor +``` + +--- + +## message list-unread-conversations — 获取未读会话列表 + +获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。 + +``` +Usage: + dws chat message list-unread-conversations [flags] +Example: + dws chat message list-unread-conversations + dws chat message list-unread-conversations --count 20 +Flags: + --count int 返回未读会话条数(可选) +``` + +--- + +## message search — 按关键词搜索消息 + +在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。 + +``` +Usage: + dws chat message search [flags] +Example: + dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message search --keyword "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 +Flags: + --keyword string 搜索关键词 (必填) + --group string 群聊 openconversation_id(可选,不传则搜索所有会话) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 100) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## conversation-info — 获取会话基础信息 + +按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。 + +``` +Usage: + dws chat conversation-info [flags] +Example: + dws chat conversation-info --group + dws chat conversation-info --open-dingtalk-id +Flags: + --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一) + +注意: + - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一 +``` + +--- + +## list-top-conversations — 拉取置顶会话列表 + +拉取当前用户的置顶会话列表。 + +``` +Usage: + dws chat list-top-conversations [flags] +Example: + dws chat list-top-conversations --limit 1000 + dws chat list-top-conversations --limit 1000 --cursor +Flags: + --limit int 每页返回数量(默认 1000) + --cursor int 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可 + - 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group ` 分别拉取每个会话内的消息 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## bot search — 搜索我的机器人 + +``` +Usage: + dws chat bot search [flags] +Example: + dws chat bot search --page 1 + dws chat bot search --page 1 --size 10 --name "日报" +Flags: + --name string 按名称搜索 + --page int 页码,从1开始 (默认 1) + --size int 每页条数 (默认 50),别名: --limit +``` + +--- + +## message send-by-bot — 机器人发消息 + +群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。 + +``` +Usage: + dws chat message send-by-bot [flags] +Example: + dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..." + dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" +Flags: + --group string 群聊 openConversationId(群聊时必填) + --robot-code string 机器人 Code (必填) + --text string 消息内容 Markdown (必填) + --title string 消息标题 (必填) + --users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填) + +注意: + - --group 与 --users 互斥,必须且只能指定其一 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) +``` + +--- + +## message recall-by-bot — 机器人撤回消息 + +群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。 + +``` +Usage: + dws chat message recall-by-bot [flags] +Example: + dws chat message recall-by-bot --robot-code --group --keys + dws chat message recall-by-bot --robot-code --keys key1,key2 +Flags: + --group string 群聊 openConversationId(群聊撤回时必填) + --keys string 消息 processQueryKey 列表,逗号分隔 (必填) + --robot-code string 机器人 Code (必填) +``` + +--- + +## message send-by-webhook — 自定义机器人 Webhook 发消息 + +@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。 + +``` +Usage: + dws chat message send-by-webhook [flags] +Example: + dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all + dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785 +Flags: + --at-all @ 所有人 + --at-mobiles string @ 指定手机号,逗号分隔 + --at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId) + --text string 消息内容 (必填) + --title string 消息标题 (必填) + --token string Webhook Token (必填) +``` + +--- + +--- + +## group get-by-group-id — 按数字群号获取会话信息 + +当用户只提供数字群号而非 openConversationId 时,用此命令把群号转换为 openConversationId,再调用其他需要 openConversationId 的命令。 + +``` +Usage: + dws chat group get-by-group-id [flags] +Example: + dws chat group get-by-group-id --group-id 12345678 +Flags: + --group-id string 群号 (必填,数字字符串) +``` + +--- + +## group transfer-owner — 转让群主 + +将群主身份转让给群内其他成员。--new-owner 传新群主的 openDingTalkId。 + +``` +Usage: + dws chat group transfer-owner [flags] +Example: + dws chat group transfer-owner --group --new-owner + # 查询群 ID: dws chat search --query "群名" + # 查询 openDingTalkId: dws contact user search --query "姓名" +Flags: + --group string 群聊 openConversationId (必填) + --new-owner string 新群主 openDingTalkId (必填) +``` + +--- + +## group invite-url — 获取群邀请链接 + +获取指定群聊的邀请加入链接。可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。 + +``` +Usage: + dws chat group invite-url [flags] +Example: + dws chat group invite-url --group + dws chat group invite-url --group --expires-seconds 86400 + dws chat group invite-url --group --expires-seconds 0 +Flags: + --group string 群聊 openConversationId (必填) + --expires-seconds int 链接有效期(秒),0=永久有效,不传使用服务端默认值 +``` + +--- + +## group quit — 退出群聊 + +当前用户退出指定群聊。 + +``` +Usage: + dws chat group quit [flags] +Example: + dws chat group quit --group +Flags: + --group string 群聊 openConversationId (必填) +``` + +--- + +## group update-icon — 更新群头像 + +更新指定群聊的群头像,--icon-media-id 传通过 media upload 获取的 mediaId。 + +``` +Usage: + dws chat group update-icon [flags] +Example: + dws chat group update-icon --group --icon-media-id +Flags: + --group string 群聊 openConversationId (必填) + --icon-media-id string 群头像 mediaId (必填,先用 media upload 上传获取) +``` + +--- + +## group update-settings — 更新群设置 + +--setting-key 指定设置项,--status 指定值(通常 "0"=关闭,"1"=开启;含义随 settingKey 变化)。 + +支持的 settingKey: `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`toolbarStatus`、`pluginCustomizeVerify`、`onlyAdminCanDING`、`allMembersCanCreateMcsConf`、`onlyAdminCanSetMsgTop`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`allMembersCanCreateCalendar`、`groupEmailDisabled`、`groupRedEnvelopeSwitch`、`groupLiveAuthority`、`groupBillAuthority` + +``` +Usage: + dws chat group update-settings [flags] +Example: + dws chat group update-settings --group --setting-key searchable --status 1 + dws chat group update-settings --group --setting-key onlyAdminCanAtAll --status 0 +Flags: + --group string 群聊 openConversationId (必填) + --setting-key string 群设置项 key (必填) + --status string 设置值: "0"=关闭, "1"=开启 (必填,字符串类型) +``` + +--- + +## group set-admin — 设置/取消群管理员 + +默认设为管理员,传 --off 取消。--users 传目标成员的 openDingTalkId 列表(逗号分隔)。 + +``` +Usage: + dws chat group set-admin [flags] +Example: + dws chat group set-admin --group --users openDingTalkId1,openDingTalkId2 + dws chat group set-admin --group --users openDingTalkId1 --off +Flags: + --group string 群聊 openConversationId (必填) + --users string 目标成员 openDingTalkId 列表 (必填,逗号分隔) + --off 传 --off 取消管理员(默认设为管理员) +``` + +--- + +## group member-role — 群自定义角色 + +`group member-role` 系列命令用于管理群的自定义身份/角色标签。包含 7 个子命令: + +- `list` — 列出群自定义角色 +- `add` — 新增群自定义角色 +- `update` — 更新群自定义角色名称 +- `remove` — 删除群自定义角色 +- `set-user` — 给成员设置自定义角色(覆盖语义,传空则清除该成员所有角色) +- `remove-user` — 移除成员的指定自定义角色(不影响其他角色) +- `query-user` — 查询成员当前持有的所有自定义角色 + +``` +Usage: + dws chat group member-role list --group + dws chat group member-role add --group --name "管理员" + dws chat group member-role update --group --role-id --name "新名称" + dws chat group member-role remove --group --role-id + dws chat group member-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group member-role remove-user --group --user --role-ids roleId1 + dws chat group member-role query-user --group --user + +注意: + - --user 传 openDingTalkId(不是 userId),可通过 `dws contact user search --query "姓名"` 获取 + - --role-id 来源于 `member-role list` 返回的 openRoleId + - `set-user` 是覆盖语义:传空 --role-ids 则清除该用户所有角色 + - `remove-user` 是增量语义:仅移除指定角色,不影响其他角色 +``` + +--- + +## message recall-by-bot — 撤回机器人发出的消息 + +> **能力范围说明**:开源 dws v1.0.30 仅提供 **机器人消息撤回**(`recall-by-bot`),不提供"撤回当前用户自己发出的消息"的命令。如果你想撤回的是普通用户消息,请在钉钉客户端长按消息撤回。 + +撤回机器人发出的消息,需要机器人 robotCode 和消息 processQueryKey。群聊撤回还需要 `--group `。 + +``` +Usage: + dws chat message recall-by-bot [flags] +Example: + # 群聊撤回机器人消息 + dws chat message recall-by-bot --robot-code --group --keys + # 单聊撤回机器人消息(不传 --group) + dws chat message recall-by-bot --robot-code --keys key1,key2 +Flags: + --robot-code string 机器人 Code (必填) + --group string 群会话 openConversationId (群聊撤回必填;单聊撤回不传) + --keys string 逗号分隔的消息 processQueryKey 列表 (必填) +``` + +--- + +## message reply — 引用回复消息 + +以当前用户身份引用某条消息并回复,**单聊与群聊均可**。需指定会话 ID、被引用消息 ID、被引用消息发送者 openDingTalkId、回复正文。 + +``` +Usage: + dws chat message reply [flags] +Example: + dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理" +Flags: + --conversation-id string 会话 openConversationId (必填,支持单聊/群聊) + --ref-msg-id string 被引用的消息 openMessageId (必填,由 message list 返回) + --ref-sender string 被引用消息发送者 openDingTalkId (必填) + --text string 回复正文 (必填) + --uuid string 幂等 UUID (可选) + +注意: + - --conversation-id 单聊和群聊使用同一字段;目前回复类型仅支持 text +``` + +--- + +## message forward — 转发消息 + +转发单条消息,**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单。 + +``` +Usage: + dws chat message forward [flags] +Example: + dws chat message forward --src-conversation-id --msg-id --dest-conversation-id +Flags: + --src-conversation-id string 源会话 openConversationId (必填) + --msg-id string 源消息 openMessageId (必填,由 message list 返回) + --dest-conversation-id string 目标会话 openConversationId (必填) +``` + +--- + +## message list-by-ids — 按消息 ID 批量获取消息 + +根据消息 ID 列表批量获取消息内容(最多 50 条)。 + +``` +Usage: + dws chat message list-by-ids [flags] +Example: + dws chat message list-by-ids --msg-ids msgId1,msgId2,msgId3 +Flags: + --msg-ids string 消息 openMessageId 列表 (必填,逗号分隔,最多 50 条) +``` + +--- + +## message query-send-status — 查询消息发送状态 + +查询通过 `message send` 等命令发送的消息的发送状态。需要传入发送时返回的 openTaskId。 + +``` +Usage: + dws chat message query-send-status [flags] +Example: + dws chat message query-send-status --open-task-id +Flags: + --open-task-id string 消息任务 ID openTaskId (必填,来自 send/send-by-bot 返回值) +``` + +--- + +## message query-read-status — 查询消息已读/未读状态 + +查询指定会话中消息的已读/未读状态(**仅消息发送者**可查询自己发出的消息)。 + +``` +Usage: + dws chat message query-read-status [flags] +Example: + dws chat message query-read-status --group --msg-id +Flags: + --group string 会话 openConversationId (必填,群聊或单聊均可) + --msg-id string 消息 openMessageId (必填,必须是当前用户发送的消息) + +注意: + - 仅消息发送者可查询自己发出的消息的已读/未读状态;查询他人发的消息会报错 + - --msg-id 从 `dws chat message list` 返回的消息列表中获取(字段名 openMessageId) +``` + +--- + +## message search-advanced — 多维度搜索消息(推荐首选) + +> 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(keyword 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索 @我 的消息)。仅在以下场景需要退回其他命令:按 userId(非 openDingTalkId)搜发送者时用 list-by-sender,拉取「特别关注人」消息时用 list-focused。 + +支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度组合搜索消息。所有参数均为可选,至少指定一个搜索条件。 + +``` +Usage: + dws chat message search-advanced [flags] +Example: + dws chat message search-advanced --keyword "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --sender-ids , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0 + dws chat message search-advanced --conversation-ids <单聊openConversationId> --keyword "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" +Flags: + --keyword string 搜索关键词(可选) + --sender-ids string 发送者 openDingTalkId 列表,逗号分隔(可选) + --at-me 只搜索 @我 的消息(可选,默认 false) + --at-ids string @指定人的 openDingTalkId 列表,逗号分隔(可选) + --conversation-ids string 会话 openConversationId 列表,逗号分隔(可选,群聊或单聊均可,不传则搜索所有会话) + --start string 开始时间,ISO-8601 格式(可选) + --end string 结束时间,ISO-8601 格式(可选) + --cursor string 分页游标(默认 "0") + --limit int 每页返回数量(默认 100) + +注意: + - 所有参数均为可选,但至少需要指定一个搜索条件 + - --sender-ids 和 --at-ids 传 openDingTalkId(非 userId),可通过 `dws contact user search --query "姓名"` 获取 + - 群聊 openConversationId 通过 `dws chat search --query "群名"` 获取;单聊 openConversationId 通过 `dws chat conversation-info --open-dingtalk-id ` 获取 + - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor + - 替代关系:完全替代 search(严格超集);大部分替代 list-by-sender(按 openDingTalkId 搜索)和 list-mentions(--at-me 覆盖核心功能);不能替代 list-focused(「特别关注」是独立维度) +``` + +--- + +## 表情回应(emoji / 文字表情) + +钉钉消息支持两种"表情回应":内置 emoji 表情 + 自定义文字表情。优先用 emoji(参见 [chat-emoji-list.md](chat-emoji-list.md) 中的 199 个默认名称,如「赞」「鼓掌」「感谢」),命中即用 `add-emoji`;未命中先用 `create-text-emotion` 创建文字表情拿到 emotionId,再 `add-text-emotion` 贴上。 + +### message add-emoji — 给消息添加 emoji 表情回应 + +``` +Usage: + dws chat message add-emoji [flags] +Example: + dws chat message add-emoji --group --msg-id --emoji "赞" + dws chat message add-emoji --group --msg-id --emoji "鼓掌" +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emoji string emoji 表情名称 (必填,参见 chat-emoji-list.md 的 name 字段) +``` + +### message remove-emoji — 移除 emoji 表情回应 + +``` +Usage: + dws chat message remove-emoji [flags] +Example: + dws chat message remove-emoji --group --msg-id --emoji "赞" +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emoji string emoji 表情名称 (必填) +``` + +### message create-text-emotion — 创建文字表情(获取 emotionId) + +当 chat-emoji-list.md 中没有所需表情时,先创建再贴。 + +``` +Usage: + dws chat message create-text-emotion [flags] +Example: + dws chat message create-text-emotion --emotion-name "赞" --text "nice" + dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5 +Flags: + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID(可选,不传则由服务端默认分配) +``` + +### message add-text-emotion — 添加文字表情回应 + +需要先通过 `create-text-emotion` 创建拿到 emotionId 才能贴。六个参数全部必填。 + +``` +Usage: + dws chat message add-text-emotion [flags] +Example: + dws chat message add-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5 +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emotion-id string 表情 ID (必填,通过 create-text-emotion 获取) + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID (必填) +``` + +### message remove-text-emotion — 移除文字表情回应 + +``` +Usage: + dws chat message remove-text-emotion [flags] +Example: + dws chat message remove-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id +Flags: + --group string 会话 openConversationId (必填) + --msg-id string 消息 openMessageId (必填) + --emotion-id string 表情 ID (必填) + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID (必填) +``` + +--- + +## message send-card — 创建并推送流式卡片 + +向群聊或单聊发送流式卡片消息。群聊传 --group,单聊传 --user(openDingTalkId),二者互斥。 + +**⚠️ 必须配套使用 `update-card`:** 创建卡片后通过 `update-card` 推送内容更新,**最后一次更新必须将 `--flow-status` 设为 3(finish)**,否则卡片会一直停在"生成中"加载态。 + +flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。 + +``` +Usage: + dws chat message send-card [flags] +Example: + dws chat message send-card --group + dws chat message send-card --user + dws chat message send-card --group --card-template-id --card-data '{"key":"value"}' +Flags: + --group string 群聊 openConversationId(群聊时必填,与 --user 互斥) + --user string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥) + --card-template-id string 卡片模板 ID(可选) + --card-data string 卡片数据 JSON(可选) +``` + +## message update-card — 流式更新卡片内容 + +--biz-id 为 send-card 返回的业务 ID,--flow-status 控制流式状态。**最后一次更新必须将 --flow-status 设为 3(finish)**。 + +``` +Usage: + dws chat message update-card [flags] +Example: + dws chat message update-card --biz-id --content "更新的卡片内容" --flow-status 2 + dws chat message update-card --biz-id --content "最终内容" --flow-status 3 +Flags: + --biz-id string 卡片业务 ID (必填,来自 send-card 返回的 bizId) + --content string 卡片消息内容 (必填) + --flow-status int 流式状态 (必填,1/2/3/4/5) +``` + +--- + +## set-top — 设置/取消会话置顶 + +设置/取消会话置顶(**单聊/群聊均可**)。默认置顶,传 --off 取消置顶。 + +``` +Usage: + dws chat set-top [flags] +Example: + dws chat set-top --group + dws chat set-top --group --off +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --off 传 --off 取消置顶(默认置顶) +``` + +--- + +## mute — 会话消息免打扰 + +开启或关闭会话消息免打扰(**单聊/群聊均可**)。默认开启,传 --off 关闭免打扰。 + +``` +Usage: + dws chat mute [flags] +Example: + dws chat mute --group + dws chat mute --group --off +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --off 传 --off 关闭免打扰(默认开启) +``` + +--- + +## group-mute — 群全员禁言 + +全员禁言/取消全员禁言。默认禁言,传 --off 取消。 + +``` +Usage: + dws chat group-mute [flags] +Example: + dws chat group-mute --group + dws chat group-mute --group --off +Flags: + --group string 群聊 openConversationId (必填) + --off 传 --off 取消全员禁言(默认开启) +``` + +--- + +## group-mute-member — 群成员禁言 + +指定群成员禁言/解除禁言。默认禁言,传 --off 解除。 + +--mute-time 为禁言时长(毫秒),仅禁言时必填,常用值:300000(5 分钟)、3600000(1 小时)、86400000(1 天)、604800000(1 周)、2592000000(30 天)。 + +``` +Usage: + dws chat group-mute-member [flags] +Example: + dws chat group-mute-member --group --users openDingTalkId1,openDingTalkId2 --mute-time 3600000 + dws chat group-mute-member --group --users openDingTalkId1 --off +Flags: + --group string 群聊 openConversationId (必填) + --users string 被禁言成员 openDingTalkId 列表 (必填,逗号分隔) + --mute-time int 禁言时长(毫秒,仅禁言时必填) + --off 传 --off 解除禁言(默认禁言) +``` + +--- + +## list-categories — 列出用户自定义会话分类 + +获取当前用户的所有自定义会话分类。 + +``` +Usage: + dws chat list-categories +Example: + dws chat list-categories +``` + +## list-conversations — 按分类拉取会话列表 + +根据分类 ID 拉取该分类下的会话列表。分类 ID 通过 `list-categories` 获取。 + +``` +Usage: + dws chat list-conversations [flags] +Example: + dws chat list-conversations --category-id +Flags: + --category-id string 分类 ID (必填,由 list-categories 返回) +``` + + +## 意图判断 + +用户说"建群/创建群聊" → `chat group create` +用户说"搜索群/找群" → `chat search` +用户说"群成员/看群里有谁" → `chat group members list` +用户说"拉人进群/加群成员" → `chat group members add` +用户说"踢人/移除群成员" → `chat group members remove` +用户说"加机器人到群" → `chat group members add-bot` +用户说"改群名" → `chat group rename` +用户说"聊天记录/会话消息/拉取会话" → `chat message list` +用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊) +用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(用户明确说"单聊"时使用) +用户说"@我的消息/at我的/提及我的" → `chat message list-mentions` +用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations` +用户说"发群消息(以个人身份)" → `chat message send --group` +用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时) +用户说"机器人发消息/机器人群发" → `chat message send-by-bot` +用户说"机器人撤回消息" → `chat message recall-by-bot` +用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook` +用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies` +用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all` +用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused` +用户说"查看我的机器人" → `chat bot search` +用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search` +用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common` +用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations` +用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info` +用户说"撤回我发的消息/撤回消息" → `chat message recall-by-bot`(仅能撤回机器人消息;撤回用户自己发的消息开源 dws v1.0.30 暂不支持,需在钉钉客户端长按撤回) +用户说"撤回机器人发的消息/机器人撤回消息" → `chat message recall-by-bot`(机器人接口撤回机器人发出的消息,需要 robot-code + processQueryKey) +用户说"引用回复/回复消息/引用消息回复" → `chat message reply` +用户说"转发消息/转发一条消息/把消息转发到另一个群" → `chat message forward` +用户说"消息已读未读/谁看了消息/查读状态/消息读取状态" → `chat message query-read-status` +用户说"查询消息发送状态/消息发没发成功/消息状态" → `chat message query-send-status` +用户说"多维度搜索消息/按发送者搜消息/按人搜消息/指定多个群搜索/@我的消息搜索" → `chat message search-advanced`(推荐首选,支持多维度组合搜索) +用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids` +用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji` +用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji` +用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion` +用户说"取消文字表情回应/移除文字表情" → `chat message remove-text-emotion` +用户说"创建文字表情/新建文字表情" → `chat message create-text-emotion` +用户说"流式卡片/AI 卡片/发送卡片" → `chat message send-card` → `chat message update-card`(必须配合 update-card,最后一次 --flow-status 必须为 3 finish) +用户说"置顶会话/取消置顶/会话置顶(操作)" → `chat set-top`(设置/取消置顶),`chat list-top-conversations`(查看置顶列表) +用户说"免打扰/消息免打扰/静音/开启免打扰/关闭免打扰" → `chat mute` +用户说"全员禁言/群禁言/解除禁言" → `chat group-mute` +用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member` +用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin` +用户说"转让群主/换群主/群主转让" → `chat group transfer-owner` +用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url` +用户说"退出群聊/我要退群" → `chat group quit` +用户说"改群头像/更新群头像" → `chat group update-icon` +用户说"群设置/修改群设置" → `chat group update-settings` +用户说"根据群号查群信息/群号查群/群号转 openConversationId" → `chat group get-by-group-id`(用户提供数字群号时把它转为 openConversationId) +用户说"查看群身份/群的自定义身份列表/群成员角色" → `chat group member-role list` +用户说"创建/添加群身份/添加群角色" → `chat group member-role add` +用户说"修改群身份名称/改群角色名" → `chat group member-role update` +用户说"删除群身份/删群角色" → `chat group member-role remove` +用户说"给某人设置群身份/设定用户角色" → `chat group member-role set-user` +用户说"移除某人的群身份/撤销群角色" → `chat group member-role remove-user` +用户说"查询某人的群身份/某人在群里有什么身份" → `chat group member-role query-user` +用户说"查看会话分类/自定义分类" → `chat list-categories` +用户说"某个分类下的会话/分类会话列表" → `chat list-conversations` + +关键区分: +- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页 +- `chat message list-direct --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用) +- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用) +- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群) +- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`) +- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令 +- `chat message list-topic-replies` — 拉取群话题的回复消息列表 +- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页 +- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页 +- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息 +- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群) +- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在) +- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag +- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息 +- `chat message recall-by-bot` — 通过机器人撤回已发送的消息 +- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等) +- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot +- `chat message recall-by-bot` — 撤回机器人发出的消息(需 --robot-code + --keys;群聊另加 --group)。撤回用户自己的消息开源 dws v1.0.30 不支持 +- `chat message reply` — 引用回复消息(单聊/群聊均可),以当前用户身份,需 --conversation-id + --ref-msg-id + --ref-sender + --text +- `chat message forward` — 转发单条消息(源/目标会话均支持单聊/群聊),需 --src-conversation-id + --msg-id + --dest-conversation-id +- `chat message list-by-ids` — 按消息 ID 列表批量查询(最多 50 条) +- `chat message query-send-status` — 查询消息发送状态,需 --open-task-id(send 返回值) +- `chat message query-read-status` — 查询消息的已读/未读状态(仅消息发送者可查询自己发的消息),需 --group + --msg-id +- `chat message search-advanced` — 多维度搜索消息(推荐首选,是 search 的严格超集,可叠加 keyword/sender-ids/at-me/at-ids/conversation-ids/start/end) +- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应(参考 chat-emoji-list.md 的 199 个默认表情) +- `chat message add-text-emotion` / `remove-text-emotion` — 添加/移除文字表情回应 +- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用 +- `chat message send-card` / `update-card` — 流式卡片,必须配套使用;最后一次 update-card 的 --flow-status 必须为 3 (finish) +- `chat set-top` — 设置/取消会话置顶(单聊/群聊均可),默认置顶,--off 取消 +- `chat mute` — 开启/关闭会话消息免打扰(单聊/群聊均可),默认开启,--off 关闭 +- `chat group-mute` — 群全员禁言开关,默认开启,--off 关闭 +- `chat group-mute-member` — 指定群成员禁言,需 --users + --mute-time(毫秒,仅禁言时必填),--off 解除 +- `chat group set-admin` — 设置/取消群管理员,默认设为管理员,--off 取消 +- `chat group transfer-owner` — 转让群主 +- `chat group invite-url` — 获取群邀请链接,可选 --expires-seconds(0=永久) +- `chat group quit` — 当前用户退出群聊 +- `chat group update-icon` / `update-settings` — 更新群头像 / 群设置 +- `chat group get-by-group-id` — 把数字群号转为 openConversationId +- `chat group member-role` — 群自定义角色管理(list/add/update/remove/set-user/remove-user/query-user) +- `chat list-categories` / `list-conversations` — 自定义会话分类列表 / 按分类拉会话 + +## 核心工作流 + +```bash +# 1. 搜索群 — 提取 openconversation_id +dws chat search --query "项目冲刺" --format json + +# 2. 拉取群消息 +dws chat message list --group --time "2025-03-01 00:00:00" --format json + +# 2b. 拉取未读会话列表 +dws chat message list-unread-conversations --count 20 --format json + +# 3. 以个人身份发送群消息 +dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" --format json + +# 4. 以个人身份单聊(通过 userId) +dws chat message send --user --title "问候" "你好" --format json + +# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用) +dws chat message send --open-dingtalk-id --title "问候" "你好" --format json + +# 5. 机器人发群消息(Markdown) +dws chat message send-by-bot --robot-code \ + --group --title "日报" --text "## 今日完成..." --format json + +# 6. 机器人单聊发消息 +dws chat message send-by-bot --robot-code \ + --users userId1,userId2 --title "提醒" --text "请提交周报" --format json + +# 7. Webhook 发告警 +dws chat message send-by-webhook --token \ + --title "告警" --text "CPU 超 90%" --at-all --format json +``` + +## 复合工作流 + +### 机器人发消息后撤回(完整流程) + +撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。 + +```bash +# Step 1: 查我的机器人 — 提取 robot-code +dws chat bot search --format json + +# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey +dws chat message send-by-bot --robot-code --group \ + --title "通知" --text "内容" --format json + +# Step 3: 用同一个 robot-code + processQueryKey 撤回 +dws chat message recall-by-bot --robot-code --group \ + --keys --format json +``` + +### 将已有机器人加入群并发消息(完整流程) + +机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。 + +```bash +# Step 1: 搜索我的机器人 — 提取 robotCode +dws chat bot search --name "项目提醒" --format json + +# Step 2: 搜索群 — 提取 openConversationId +dws chat search --query "项目群" --format json + +# Step 3: 将机器人添加到群(需当前用户对该群有管理权限) +dws chat group members add-bot --id --robot-code --format json + +# Step 4: 机器人发消息 +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "请及时更新项目状态" --format json +``` + +### 机器人 @指定人发群消息 + +`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。 + +```bash +# Step 1: 搜人获取 userId +dws aisearch person --keyword "张三" --dimension name --format json + +# Step 2: 取 userId 发送(注意 text 中的占位符) +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json +``` + +### 发送图片/文件消息(跨产品: drive → chat) + +```bash +# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证 +dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json + +# Step 2: HTTP PUT 上传文件到 OSS +curl -X PUT -T "截图.png" "" + +# Step 3: 提交上传 — 获取 dentryUuid +dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id --format json + +# Step 4: 获取下载链接 +dws drive download --file-id --format json + +# Step 5: 用 Markdown 图片语法发送 +dws chat message send --group \ + --title "截图" --text "![截图](下载链接)" --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `chat search` | `openConversationId` | message send/list、group members 等的 --group | +| `chat group create` | `openConversationId` | 同上 | +| `chat group get-by-group-id` | `openConversationId` | 把数字群号转换后给 message send/list 等命令使用 | +| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor | +| `chat message search` / `search-advanced` | `nextCursor` | 下次 message search/search-advanced 的 --cursor | +| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id | +| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id、search-advanced 的 --sender-ids/--at-ids、reply 的 --ref-sender、group transfer-owner 的 --new-owner、group set-admin / group-mute-member 的 --users、group member-role 系列的 --user | +| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code | +| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | +| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id | +| `chat message list` | `openMessageId` | recall 的 --msg-id、reply 的 --ref-msg-id、forward 的 --msg-id、query-read-status 的 --msg-id、list-by-ids 的 --msg-ids、add-emoji/add-text-emotion 的 --msg-id | +| `chat conversation-info` | `openConversationId` / `newCSpaceIdIM` | 作为 message send/list 等后续操作的会话上下文;newCSpaceIdIM 用于 drive upload-info 的 --space-id | +| `chat search-common` | `openConversationId` | message send/list 等的 --group | +| `chat list-categories` | `categoryId` | list-conversations 的 --category-id | +| `chat group member-role list` | `openRoleId` | group member-role update/remove/set-user/remove-user 的 --role-id / --role-ids | +| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | +| `chat message send-card` | `bizId` | update-card 的 --biz-id | +| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 | + +## 注意事项 + +- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取 +- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id` +- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor +- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一 +- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识 +- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部 +- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数 +- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话 +- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑 +- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor` +- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一 +- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) +- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效 + +- **发消息前参数审查(必须执行)**:发消息(`message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`)是严肃操作,发错人/发错群会导致严重问题。执行前 agent 必须把全部参数(收件人/群、消息内容、@对象、消息类型等)与用户的原始需求逐一对比,确认每个参数都能从原始需求中找到明确依据;存在任何不明确/有歧义/原始需求中未提及的参数,必须先向用户确认,严禁自行假设 +- **消息换行符**:`message send` / `send-by-bot` / `send-by-webhook` 的 `--text` 按 Markdown 渲染,换行有两层要求,缺一不可:(1) 必须使用**真实换行符** `U+000A`,而非字面量字符串 `\n`;(2) Markdown 单换行不产生换行,需用空行(连续两个真实换行符)做段落分隔,或行尾两空格 + 换行 / `
` 做硬换行 +- `chat message recall` 撤回当前用户自己发的消息,--group + --msg-id 必填;与 `recall-by-bot`(撤回机器人消息,--robot-code + --keys)区分 +- `chat message reply` 引用回复(单聊/群聊均可),需 --conversation-id + --ref-msg-id + --ref-sender + --text;目前回复类型仅支持 text +- `chat message forward` 转发消息(源/目标会话单聊群聊均可),需 --src-conversation-id + --msg-id + --dest-conversation-id +- `chat message query-send-status` 查询消息发送状态,需 --open-task-id(send 返回值) +- `chat message query-read-status` 查询消息已读/未读状态,仅消息发送者可查询自己发的消息,需 --group + --msg-id +- `chat message search-advanced` 多维度搜索(推荐首选),所有参数可选但至少传一个;--sender-ids / --at-ids 传 openDingTalkId +- `chat message list-by-ids` --msg-ids 逗号分隔,最多 50 条 +- `chat message add-emoji` / `remove-emoji` 需 --group + --msg-id + --emoji;--emoji 取自 chat-emoji-list.md 的 name 列 +- `chat message add-text-emotion` / `remove-text-emotion` 六个参数全部必填:--group + --msg-id + --emotion-id + --emotion-name + --text + --background-id +- `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选 +- `chat message send-card` 创建并推送流式卡片,群聊传 --group,单聊传 --user(openDingTalkId);**必须配合 update-card 使用,最后一次 update-card 的 --flow-status 必须为 3(finish)**,否则卡片会一直显示"生成中" +- `chat set-top` 设置/取消会话置顶(单聊/群聊均可),需 --group,默认置顶,--off 取消 +- `chat mute` 开启/关闭会话消息免打扰(单聊/群聊均可),需 --group,默认开启,--off 关闭 +- `chat group-mute` 群全员禁言开关,需 --group,默认开启,--off 关闭 +- `chat group-mute-member` 指定成员禁言,需 --group + --users(openDingTalkId 逗号分隔)+ --mute-time(毫秒,仅禁言时必填,常用 300000/3600000/86400000/604800000/2592000000),--off 解除 +- `chat group set-admin` 设置/取消群管理员,需 --group + --users(openDingTalkId 逗号分隔),默认设为管理员,--off 取消 +- `chat group transfer-owner` 转让群主,需 --group + --new-owner(openDingTalkId) +- `chat group invite-url` 获取群邀请链接,需 --group,可选 --expires-seconds(0=永久有效) +- `chat group quit` 退出群聊,需 --group +- `chat group update-icon` 更新群头像,需 --group + --icon-media-id(mediaId) +- `chat group update-settings` 更新群设置,需 --group + --setting-key + --status(字符串类型 "0"/"1") +- `chat group get-by-group-id` 把数字群号转换为 openConversationId,需 --group-id(数字字符串) +- `chat group member-role` 系列管理群自定义角色:list 查列表,add 创建,update 改名,remove 删除;set-user 覆盖某人全部角色(传空 --role-ids 则清除),remove-user 仅移除指定角色,query-user 查询某人当前角色;--user 传 openDingTalkId(非 userId) +- `chat list-categories` 列出当前用户自定义会话分类(无参数) +- `chat list-conversations` 按分类拉取会话列表,需 --category-id(字符串,由 list-categories 返回) +- **如何获取 openConversationId**(上层已有则直接用,不必重复查):群聊 `dws chat search --query "群名"`;单聊 `dws chat conversation-info --open-dingtalk-id ` +- **如何获取 openDingTalkId**:知道姓名 → `dws contact user search --query "姓名"`;只有 userId → 先 `dws contact user get --ids ` 拿姓名,再 search + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | +| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | + +## 相关产品 + +- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id +- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息 diff --git a/skills/references/products/contact.md b/skills/mono/references/products/contact.md similarity index 100% rename from skills/references/products/contact.md rename to skills/mono/references/products/contact.md diff --git a/skills/references/products/devdoc.md b/skills/mono/references/products/devdoc.md similarity index 100% rename from skills/references/products/devdoc.md rename to skills/mono/references/products/devdoc.md diff --git a/skills/references/products/ding.md b/skills/mono/references/products/ding.md similarity index 100% rename from skills/references/products/ding.md rename to skills/mono/references/products/ding.md diff --git a/skills/references/products/doc.md b/skills/mono/references/products/doc.md similarity index 100% rename from skills/references/products/doc.md rename to skills/mono/references/products/doc.md diff --git a/skills/references/products/drive.md b/skills/mono/references/products/drive.md similarity index 69% rename from skills/references/products/drive.md rename to skills/mono/references/products/drive.md index 2d1a156f..a655d360 100644 --- a/skills/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -4,6 +4,22 @@ ## 命令总览 +### 列出钉盘空间 +``` +Usage: + dws drive list-spaces [flags] +Example: + dws drive list-spaces --format json + dws drive list-spaces --space-type org --max 50 --format json +Flags: + --cursor string 分页游标 (从上次结果的 nextToken 获取) + --max string 每页数量 + --space-type string 空间类型过滤 +``` + +> 适用场景:复制/移动文件到「我的文件」或团队空间根目录时,先取 `rootFolderId`;或者枚举用户可访问的团队空间。 +> 注意:由企业服务发现 envelope 注册;个别企业 MCP gateway 可能未开通,调用前可用 `dws drive list-spaces --help` / `--dry-run` 验证。 + ### 列出钉盘目录 ``` Usage: @@ -87,15 +103,56 @@ Flags: --conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME) ``` +### 一键上传 (三步合成) +``` +Usage: + dws drive upload [flags] +Example: + dws drive upload --file ./report.pdf + dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx" + dws drive upload --file ./data.xlsx --folder +Flags: + --file string 本地文件路径 (必填) + --file-name string 文件显示名称 (默认使用本地文件名) + --folder string 父节点 ID (dentryUuid),不传则上传到空间根目录 + --space-id string 目标空间 ID,不传则使用「我的文件」 + --mime-type string 文件 MIME 类型,不传则自动推断 +``` + +> 客户端胶水命令:内部自动完成 `upload-info` → HTTP PUT 到 OSS → `commit_upload` 三步。 +> 适合大多数本地文件上传场景,无需 Agent 自己发 HTTP PUT。 +> 注意:`--folder` 接受 `dentryUuid`(UUID 格式),不要传纯数字 `dentryId`。 + +### 删除文件/文件夹到回收站 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws drive delete [flags] +Example: + dws drive delete --node --yes --format json +Flags: + --node string 文件或文件夹 nodeId(dentryUuid 格式,必填) + +Global Flags: + --yes 跳过二次确认 (危险操作,建议先与用户确认) +``` + +> 由企业服务发现 envelope 注册(路由到 doc 服务的 `delete_document` tool);不同企业 MCP gateway 可能不暴露,调用前可用 `dws drive delete --help` 验证。 +> 删除是软删除(进回收站),但仍需用户明确确认;不要在自动化脚本里默认带 `--yes`。 + ## 意图判断 +用户说"钉盘有什么空间/团队文件/列空间" → `list-spaces` 用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list` 用户说"钉盘文件详情/文件信息" → `info` (需 fileId) 用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir` 用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET 用户说"上传文件到钉盘/把本地文件传钉盘": -- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit` -- **没有**一条聚合命令可以完成;必须完整走完三步 +- **首选 `upload`**:一条命令自动完成三步(适合本地文件直接上传) +- 也可手动三步:`upload-info` → HTTP PUT 到预签名 URL → `commit`(适合自定义流式上传,或不想在客户端做 HTTP PUT 的场景) +用户说"删除钉盘文件/移到回收站" → `delete`(不可逆,必须确认) 关键区分: - drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容) @@ -162,8 +219,8 @@ dws drive mkdir --name "2026 Q1 归档" --format json ## 注意事项 -- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败 -- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit` +- 上传两条路径:**首选** `upload` 一条命令搞定(内部走三步);如果需要自己控制 HTTP PUT,可走 `upload-info` → 客户端 HTTP PUT → `commit` 三步 +- 用三步流程时,Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit` - 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info` - `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET - `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配 diff --git a/skills/references/products/mail.md b/skills/mono/references/products/mail.md similarity index 97% rename from skills/references/products/mail.md rename to skills/mono/references/products/mail.md index 97f62b1d..b6f69dbe 100644 --- a/skills/references/products/mail.md +++ b/skills/mono/references/products/mail.md @@ -14,7 +14,7 @@ Example: | 字段 | 类型 | 说明 | |------|------|------| -| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 | +| `emailAccounts` | `List[]` | 邮箱列表,每条含 `email` / `type` / `orgName`;type 取值 `PERSONAL`(个人邮箱)/ `ORGANIZATION`(企业邮箱) | ### 搜索邮件 (KQL 语法) ``` diff --git a/skills/references/products/minutes.md b/skills/mono/references/products/minutes.md similarity index 100% rename from skills/references/products/minutes.md rename to skills/mono/references/products/minutes.md diff --git a/skills/references/products/oa.md b/skills/mono/references/products/oa.md similarity index 89% rename from skills/references/products/oa.md rename to skills/mono/references/products/oa.md index ae544b11..df0e68ed 100644 --- a/skills/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -26,15 +26,15 @@ Flags: Usage: dws oa approval list-initiated [flags] Example: - dws oa approval list-initiated - dws oa approval list-initiated --process-code --max-results 20 - dws oa approval list-initiated --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --next-token + dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" + dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --max-results 20 + dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --next-token Flags: - --process-code string 按指定表单/流程模板过滤 (可选) - --start string 发起开始时间 ISO-8601 (可选) - --end string 发起结束时间 ISO-8601 (可选) - --max-results string 每页条数 (可选) - --next-token string 分页 token (首页留空) + --process-code string 审批表单 processCode (必填) — 来自 dws oa approval list-forms + --start string 发起开始时间 ISO-8601 (必填) + --end string 发起结束时间 ISO-8601 (可选, 缺省查到当前) + --max-results int 每页大小 (最大 20, 默认 20) + --next-token int 分页游标 (首次传 0) ``` 查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。 @@ -164,9 +164,9 @@ dws oa approval reject --instance-id --task-id --r # 5. 查看流程记录 — 谁何时做了什么 dws oa approval records --instance-id --format json -# 6. 查我发起的审批 / 按模板过滤 -dws oa approval list-initiated --format json -dws oa approval list-initiated --process-code --format json +# 6. 查我发起的审批(--process-code 和 --start 都是必填,processCode 先 list-forms 拿到) +dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --format json +dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --format json # 7. 能发起哪些表单 dws oa approval list-forms --cursor 0 --size 20 --format json diff --git a/skills/references/products/report.md b/skills/mono/references/products/report.md similarity index 97% rename from skills/references/products/report.md rename to skills/mono/references/products/report.md index 2a856db9..749dedd7 100644 --- a/skills/references/products/report.md +++ b/skills/mono/references/products/report.md @@ -68,9 +68,9 @@ Usage: Example: dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20 Flags: - --cursor string 分页游标 (必填) + --cursor int 分页游标,首次传 0 (可选,默认 0) --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填) - --size string 每页大小 (必填) + --size int 每页条数,最大 20 (可选,默认 20) --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填) ``` diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md new file mode 100644 index 00000000..40c05489 --- /dev/null +++ b/skills/mono/references/products/sheet.md @@ -0,0 +1,235 @@ +# 在线电子表格 (sheet) 命令参考 + +> ⚠️ **本文档已对齐开源 dws v1.0.30 实际暴露的 cobra 子命令**。文档中描述的所有命令在该版本下都能 dispatch 成功(PASS),仅业务返回值取决于真实数据 / 权限。 + +## 适用范围(重要) + +`sheet` 产品**仅支持钉钉在线电子表格**(`contentType=ALIDOC`、`extension=axls`),**不支持**上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。 + +| 文件类型 | 处理方式 | +|---------|---------| +| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并等服务端原子操作) | +| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 | + +> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node --format json` 确认 `contentType=ALIDOC` 且 `extension=axls` 后再走 `sheet`;否则转 `dws doc download`。 + +## 命令总览(按功能分组) + +### 工作表 (Worksheet) 级 + +| 命令 | 用途 | +|------|------| +| `dws sheet create` | 在知识库中创建一个新的钉钉表格文档 | +| `dws sheet new` | 在已有钉钉表格文档中新建一张工作表 | +| `dws sheet list` | 列出指定文档的所有工作表 | +| `dws sheet info` | 获取指定工作表详情 | + +### 区域 (Range) 读写 + +| 命令 | 用途 | +|------|------| +| `dws sheet range read` | 读取指定区域的单元格内容 | +| `dws sheet range update` | 写入/更新指定区域的单元格 | +| `dws sheet append` | 在工作表末尾追加若干行 | + +### 行列 (Dimension) + +| 命令 | 用途 | +|------|------| +| `dws sheet add-dimension` | 在末尾追加空行或空列 | +| `dws sheet insert-dimension` | 在指定位置插入空行/空列 | +| `dws sheet delete-dimension` | 删除指定位置起的若干行/列 | +| `dws sheet move-dimension` | 移动行/列到指定位置 | +| `dws sheet update-dimension` | 更新行/列属性(显隐、行高/列宽) | + +### 单元格合并 + +| 命令 | 用途 | +|------|------| +| `dws sheet merge-cells` | 合并指定范围的单元格(`mergeAll`/`mergeRows`/`mergeColumns`) | +| `dws sheet unmerge-cells` | 取消指定范围的合并 | + +### 查找/替换 + +| 命令 | 用途 | +|------|------| +| `dws sheet find` | 在工作表中搜索单元格内容(支持正则/整格匹配/隐藏) | +| `dws sheet replace` | 全局查找替换 | + +### 筛选视图 (Filter View) — 命名视图、按列条件、不影响表本身 + +| 命令 | 用途 | +|------|------| +| `dws sheet filter-view create` | 创建筛选视图 | +| `dws sheet filter-view list` | 列出工作表的所有筛选视图 | +| `dws sheet filter-view update` | 更新筛选视图(名称/范围/条件) | +| `dws sheet filter-view delete` | 删除整个筛选视图 | +| `dws sheet filter-view update-criteria` | 设置/更新视图内某列的筛选条件 | +| `dws sheet filter-view delete-criteria` | 清除视图内某列的筛选条件 | + +### 图片 + +| 命令 | 用途 | +|------|------| +| `dws sheet write-image` | 将已上传的图片资源写入指定单元格(需要 `--resource-id` 或 `--resource-url`) | + +## 通用必填参数 + +绝大多数 `sheet` 命令都需要: + +- `--node ` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/` URL +- `--sheet-id ` —— 工作表 ID,从 `dws sheet list` 拿 + +例外: +- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId) +- `list` / `info` / `range read` 只需要 `--node` + +## 常用命令示例 + +### 创建文档 + 新建工作表 + +```bash +# 在知识库下创建一个钉钉表格文档 +dws sheet create --name "销售数据" --workspace --format json +# 返回的 nodeId 用于后续操作 + +# 在已有文档中新建一张工作表 +dws sheet new --node --name "Q1 数据" --format json +``` + +### 读写区域 + +```bash +# 读 A1:D10 +dws sheet range read --node --sheet-id --range "A1:D10" --format json + +# 写入 5x4 区域(values 是二维 JSON 数组) +dws sheet range update --node --sheet-id --range "A1:D5" \ + --values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \ + --format json + +# 追加行 +dws sheet append --node --sheet-id \ + --values '[["李四","产品","2025-03","28000"]]' \ + --format json +``` + +### 行列操作 + +```bash +# 在第 5 行处插入 2 个空行 +dws sheet insert-dimension --node --sheet-id \ + --dimension rows --position 4 --length 2 --format json + +# 末尾追加 3 列 +dws sheet add-dimension --node --sheet-id \ + --dimension columns --length 3 --format json + +# 删除第 10-12 行 +dws sheet delete-dimension --node --sheet-id \ + --dimension rows --position 9 --length 3 --format json + +# 隐藏 B 列(startIndex=1, length=1) +dws sheet update-dimension --node --sheet-id \ + --dimension columns --start-index 1 --length 1 --hidden true --format json +``` + +### 合并/取消合并 + +```bash +# 合并 A1:C1 +dws sheet merge-cells --node --sheet-id \ + --range "A1:C1" --merge-type mergeAll --format json + +# 取消合并 +dws sheet unmerge-cells --node --sheet-id \ + --range "A1:C1" --format json +``` + +### 查找/替换 + +```bash +# 查找 +dws sheet find --node --sheet-id \ + --find "TODO" --match-case false --format json + +# 全局替换 +dws sheet replace --node --sheet-id \ + --find "TODO" --replacement "DONE" --format json +``` + +### 筛选视图 + +```bash +# 创建筛选视图(范围必须包含表头行) +dws sheet filter-view create --node --sheet-id \ + --name "未完成项" --range "A1:E100" --format json +# 返回的 filterViewId 用于后续 update/delete/criteria 操作 + +# 列出所有筛选视图 +dws sheet filter-view list --node --sheet-id --format json + +# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移) +dws sheet filter-view update-criteria --node --sheet-id \ + --filter-view-id --column 2 \ + --filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \ + --format json + +# 清除某列的筛选条件(不删除视图本身) +dws sheet filter-view delete-criteria --node --sheet-id \ + --filter-view-id --column 2 --format json + +# 删除整个筛选视图 +dws sheet filter-view delete --node --sheet-id \ + --filter-view-id --format json +``` + +### 写入图片 + +```bash +# 已有图片资源 ID 和 URL 后写入单元格 +dws sheet write-image --node --sheet-id \ + --range "B2:B2" --resource-id --resource-url \ + --width 200 --height 100 --format json +``` + +## 易混淆点 + +| 区分 | 说明 | +|---|---| +| `dws sheet create` vs `dws sheet new` | `create` 在知识库**新建一个文档**(返回新 nodeId);`new` 在**已有文档中新建一张工作表**(需 nodeId) | +| `filter-view update-criteria` vs `filter-view delete-criteria` | update 是设置/覆盖列条件;delete 是清除列条件(视图本身保留);要删整个视图用 `filter-view delete` | +| `dws sheet write-image` vs `range update` | write-image 写入图片(需 resourceId + resourceUrl);range update 写入文本/数字/公式 | +| `range update` vs `append` | range update 指定区域覆盖;append 在末尾追加行 | +| online axls vs 本地 xlsx | sheet 全部命令只认 axls;本地 xlsx 必须先 `doc download` 再用本地工具解析 | + +## 危险操作(必须先向用户确认) + +| 命令 | 风险 | +|---|---| +| `delete-dimension` | 删除行/列(含数据),不可恢复 | +| `filter-view delete` | 删除整个筛选视图 | +| `replace` | 全局替换可能影响大量单元格 | +| `unmerge-cells` | 取消合并可能丢失部分单元格内容 | + +执行前先 `--dry-run` 预览,并向用户展示操作摘要 + 拿到明确同意,再加 `--yes` 提交。 + +## 何时**不要**用 sheet + +- 用户给的是 `xlsx` / `xls` / `xlsm` / `csv` 本地文件 → 用 `dws doc download` 下载后本地解析 +- 用户给的是 AI 表格(不是在线电子表格)→ 用 `dws aitable record query` 等 +- 用户给的是富文本/普通文档 → 用 `dws doc read` + +## 不支持的能力(开源 dws v1.0.30) + +以下命令在内部 wukong 版本可能可用,但**开源 v1.0.30 未暴露**,请勿尝试调用: + +- 复制工作表 / 更新工作表元信息(`copy_sheet` / `update_sheet`) +- 表级筛选器(`create_filter / get_filter / update_filter / delete_filter / set_filter_criteria / clear_filter_criteria / sort_filter`)—— 改用筛选视图(`filter-view *`)实现等价功能 +- 导出 xlsx(`submit_export_job / query_export_job`)—— 改在钉钉客户端导出 +- 浮动图片(float-image 系列)/ 下拉框(dropdown 系列)/ 单元格样式(range set-style 系列) + +## 权威参考 + +- 看某个命令的完整 help:`dws sheet --help` +- 看 schema:`dws schema sheet.` diff --git a/skills/references/products/simple.md b/skills/mono/references/products/simple.md similarity index 92% rename from skills/references/products/simple.md rename to skills/mono/references/products/simple.md index b58ddaef..cdaa9e27 100644 --- a/skills/references/products/simple.md +++ b/skills/mono/references/products/simple.md @@ -11,11 +11,11 @@ Usage: dws devdoc article search [flags] Example: - dws devdoc article search --keyword "OAuth2 接入" --page 1 --size 10 + dws devdoc article search --query "OAuth2 接入" --page 1 --size 10 Flags: - --keyword string 搜索关键词 (必填) - --page string 页码 (默认 1) - --size string 每页数量 (默认 10) + --query string 搜索关键词 (必填) + --page string 页码 (默认 1) + --size string 每页数量 (默认 10) ``` --- diff --git a/skills/references/products/todo.md b/skills/mono/references/products/todo.md similarity index 100% rename from skills/references/products/todo.md rename to skills/mono/references/products/todo.md diff --git a/skills/references/products/wiki.md b/skills/mono/references/products/wiki.md similarity index 92% rename from skills/references/products/wiki.md rename to skills/mono/references/products/wiki.md index 64427c75..d4e6ea91 100644 --- a/skills/references/products/wiki.md +++ b/skills/mono/references/products/wiki.md @@ -53,7 +53,7 @@ Usage: Example: dws wiki space search --keyword "产品文档" --format json dws wiki space search --keyword "技术方案" --limit 20 --format json - dws wiki space search --type myWikiSpace --format json + dws wiki space list --type myWikiSpace --format json Flags: --keyword string 搜索关键词 (--type myWikiSpace 时可省略) --type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库 @@ -80,7 +80,7 @@ Flags: > - 仅支持 USER 类型。 > - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。 > - 操作者需具备知识库的 OWNER 或 MANAGER 权限。 -> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws doc permission add` 在节点级别授权。 +> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,节点级权限授权在开源 dws v1.0.30 暂不支持,请在钉钉客户端文档中通过「分享」设置。 ### 修改知识库成员角色 ``` @@ -117,7 +117,7 @@ Flags: - 用户说"查看知识库/知识库详情" → `space get` - 用户说"我的知识库/知识库列表/有哪些知识库" → `space list` - 用户说"搜索知识库/找知识库" → `space search` -- 用户说"我的文档/个人空间" → `space search --type myWikiSpace` 或 `space list --type myWikiSpace` +- 用户说"我的文档/个人空间" → `space list --type myWikiSpace` - 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`) - 用户说"修改某人在知识库的权限/调整成员角色" → `member update` - 用户说"知识库有哪些成员/查看知识库成员" → `member list` @@ -125,8 +125,8 @@ Flags: 关键区分: - wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限) - wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载) -- **wiki member**(容器级,授权整个知识库)vs **doc permission**(节点级,授权单篇文档) - - 「我的文档」**只能用** `doc permission`,不能用 `wiki member` +- **wiki member**(容器级,授权整个知识库);节点级 `doc permission` 在开源 dws v1.0.30 不可用 + - 「我的文档」当前开源版本不能通过 CLI 改权限;在钉钉客户端用「分享」设置 ## 核心工作流 @@ -141,7 +141,7 @@ dws wiki space list --type myWikiSpace --format json dws wiki space search --keyword "产品" --format json # 搜索「我的文档」 -dws wiki space search --type myWikiSpace --format json +dws wiki space list --type myWikiSpace --format json # 创建知识库 dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json diff --git a/skills/references/recovery-guide.md b/skills/mono/references/recovery-guide.md similarity index 100% rename from skills/references/recovery-guide.md rename to skills/mono/references/recovery-guide.md diff --git a/skills/references/url-patterns.md b/skills/mono/references/url-patterns.md similarity index 97% rename from skills/references/url-patterns.md rename to skills/mono/references/url-patterns.md index 29a58b2c..fadd529f 100644 --- a/skills/references/url-patterns.md +++ b/skills/mono/references/url-patterns.md @@ -98,7 +98,7 @@ Step 3 → 按下方路由规则映射到对应产品 > axls vs xlsx 关键区分: > - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) > - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) -> - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet submit_export_job` 提交导出任务(输入是 `axls`,输出是 xlsx,这是 axls → xlsx 的格式转换,不属于 xlsx 读取场景) +> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(envelope schema 里有 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx ### 示例 diff --git a/skills/scripts/attendance_my_record.py b/skills/mono/scripts/attendance_my_record.py similarity index 100% rename from skills/scripts/attendance_my_record.py rename to skills/mono/scripts/attendance_my_record.py diff --git a/skills/scripts/attendance_team_shift.py b/skills/mono/scripts/attendance_team_shift.py similarity index 100% rename from skills/scripts/attendance_team_shift.py rename to skills/mono/scripts/attendance_team_shift.py diff --git a/skills/scripts/bulk_add_fields.py b/skills/mono/scripts/bulk_add_fields.py similarity index 100% rename from skills/scripts/bulk_add_fields.py rename to skills/mono/scripts/bulk_add_fields.py diff --git a/skills/scripts/calendar_free_slot_finder.py b/skills/mono/scripts/calendar_free_slot_finder.py similarity index 100% rename from skills/scripts/calendar_free_slot_finder.py rename to skills/mono/scripts/calendar_free_slot_finder.py diff --git a/skills/scripts/calendar_schedule_meeting.py b/skills/mono/scripts/calendar_schedule_meeting.py similarity index 100% rename from skills/scripts/calendar_schedule_meeting.py rename to skills/mono/scripts/calendar_schedule_meeting.py diff --git a/skills/scripts/calendar_today_agenda.py b/skills/mono/scripts/calendar_today_agenda.py similarity index 100% rename from skills/scripts/calendar_today_agenda.py rename to skills/mono/scripts/calendar_today_agenda.py diff --git a/skills/mono/scripts/chat_export_messages.py b/skills/mono/scripts/chat_export_messages.py new file mode 100644 index 00000000..54324607 --- /dev/null +++ b/skills/mono/scripts/chat_export_messages.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +导出群聊消息到 JSON 文件(从指定时间点拉取) + +用法: + python chat_export_messages.py \ + --group \ + --time "2026-03-10 00:00:00" \ + --output messages.json + + python chat_export_messages.py \ + --query "项目冲刺" \ + --time "2026-03-10 00:00:00" \ + --no-forward --limit 100 +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def search_group( + query: str, dry_run: bool = False, +) -> Optional[str]: + data = run_dws([ + 'chat', 'search', + '--query', query, '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + if isinstance(data, list): + groups = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + groups = inner.get('items', inner.get('groups', [])) + elif isinstance(inner, list): + groups = inner + else: + groups = [] + else: + groups = [] + if not groups: + print(f"未找到群聊: {query}") + return None + g = groups[0] + name = g.get('title') or g.get('name', '未知') + conv_id = g.get('openConversationId') or g.get('id') + print(f" 找到群聊: {name} ({conv_id})") + return conv_id + + +def main(): + parser = argparse.ArgumentParser( + description='导出群聊消息到 JSON' + ) + parser.add_argument('--group', help='群聊 openconversation_id') + parser.add_argument('--query', help='按群名搜索') + parser.add_argument( + '--time', required=True, + help='起始时间 yyyy-MM-dd HH:mm:ss', + ) + parser.add_argument( + '--no-forward', action='store_true', + help='拉给定时间之前的消息 (默认拉给定时间之后)', + ) + parser.add_argument( + '--limit', type=int, default=0, + help='返回条数 (不传则不限制)', + ) + parser.add_argument('--output', default='', help='输出文件') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + conv_id = args.group + if not conv_id: + if not args.query: + print('错误:需要 --group 或 --query 参数') + sys.exit(1) + print(f'🔍 搜索群聊: {args.query}') + conv_id = search_group(args.query, args.dry_run) + if not conv_id and not args.dry_run: + sys.exit(1) + + print(f'📥 拉取消息 (起始: {args.time})...') + all_messages: List[Any] = [] + current_time = args.time + page = 0 + max_pages = 50 + remaining = args.limit if args.limit > 0 else float('inf') + + while page < max_pages and remaining > 0: + cmd_args = [ + 'chat', 'message', 'list', + '--group', conv_id or '', + '--time', current_time, + '--format', 'json', + ] + if args.no_forward: + cmd_args.append('--forward=false') + page_limit = min(int(remaining), 200) if args.limit > 0 else 0 + if page_limit > 0: + cmd_args.extend(['--limit', str(page_limit)]) + data = run_dws(cmd_args, dry_run=args.dry_run) + + if args.dry_run: + print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') + return + + if not data: + break + + if isinstance(data, list): + page_msgs = data + has_more = False + else: + page_msgs = data.get('messages', data.get('result', [])) + has_more = data.get('hasMore', False) + + if not page_msgs: + break + + all_messages.extend(page_msgs) + remaining -= len(page_msgs) + page += 1 + + if not has_more: + break + + last_msg = page_msgs[-1] + boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + if not boundary_time or boundary_time == current_time: + break + current_time = boundary_time + print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") + + if not all_messages: + print('未拉取到消息') + return + + if isinstance(data, list): + messages = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + messages = inner.get('messages', inner.get('records', [])) + elif isinstance(inner, list): + messages = inner + else: + messages = [] + else: + messages = [] + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(all_messages, f, ensure_ascii=False, indent=2) + print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") + else: + for m in all_messages: + sender = m.get('senderNick') or m.get('sender', '未知') + text = m.get('text') or m.get('content', '') + time_str = m.get('createAt') or m.get('time', '') + print(f" [{time_str}] {sender}: {text[:80]}") + print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") + + +if __name__ == '__main__': + main() diff --git a/skills/mono/scripts/chat_history_with_user.py b/skills/mono/scripts/chat_history_with_user.py new file mode 100644 index 00000000..8068be38 --- /dev/null +++ b/skills/mono/scripts/chat_history_with_user.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +查询与某人的单聊聊天记录 + +用法: + python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00" + python chat_history_with_user.py --user --time "2026-03-10 00:00:00" --limit 50 + python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json + +工作流: + 1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user) + 2. 调用 chat message list --user 拉取单聊消息 + 3. 输出到终端或导出为 JSON 文件 +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + """执行 dws 命令并解析 JSON 输出""" + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def search_user( + name: str, dry_run: bool = False, +) -> Optional[str]: + """按关键词搜索用户,返回第一个匹配的 userId""" + data = run_dws([ + 'contact', 'user', 'search', + '--query', name, '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + # 解析返回结构 + users = data + if isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + users = (inner.get('users', []) + or inner.get('list', [])) + elif isinstance(inner, list): + users = inner + else: + users = [] + if not users or not isinstance(users, list): + print(f"未找到用户: {name}") + return None + u = users[0] + user_name = u.get('name') or u.get('nick', '未知') + user_id = u.get('userId') or u.get('userid', '') + print(f" 找到用户: {user_name} ({user_id})") + return user_id + + +def main(): + parser = argparse.ArgumentParser( + description='查询与某人的单聊聊天记录' + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--name', help='按姓名搜索用户') + group.add_argument('--user', help='直接指定 userId') + parser.add_argument( + '--time', required=True, + help='起始时间 yyyy-MM-dd HH:mm:ss', + ) + parser.add_argument( + '--no-forward', action='store_true', + help='拉给定时间之前的消息 (默认拉给定时间之后)', + ) + parser.add_argument( + '--limit', type=int, default=0, + help='返回条数 (不传则不限制)', + ) + parser.add_argument('--output', default='', help='导出到 JSON 文件') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + # 1. 获取 userId + user_id = args.user + if not user_id: + print(f'🔍 搜索用户: {args.name}') + user_id = search_user(args.name, args.dry_run) + if not user_id and not args.dry_run: + sys.exit(1) + + # 2. 拉取单聊消息(自动翻页) + print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...') + all_messages: List[Any] = [] + current_time = args.time + page = 0 + max_pages = 50 + remaining = args.limit if args.limit > 0 else float('inf') + + while page < max_pages and remaining > 0: + cmd_args = [ + 'chat', 'message', 'list', + '--user', user_id or '', + '--time', current_time, + '--format', 'json', + ] + if args.no_forward: + cmd_args.append('--forward=false') + page_limit = min(int(remaining), 200) if args.limit > 0 else 0 + if page_limit > 0: + cmd_args.extend(['--limit', str(page_limit)]) + data = run_dws(cmd_args, dry_run=args.dry_run) + + if args.dry_run: + print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') + return + + if not data: + break + + if isinstance(data, list): + page_msgs = data + has_more = False + else: + page_msgs = data.get('messages', data.get('result', [])) + has_more = data.get('hasMore', False) + + if not page_msgs: + break + + all_messages.extend(page_msgs) + remaining -= len(page_msgs) + page += 1 + + if not has_more: + break + + last_msg = page_msgs[-1] + boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + if not boundary_time or boundary_time == current_time: + break + current_time = boundary_time + print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") + + if not all_messages: + print('未拉取到消息') + return + + # 3. 输出结果 + if isinstance(data, list): + messages = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + messages = inner.get('messages', inner.get('records', [])) + elif isinstance(inner, list): + messages = inner + else: + messages = [] + else: + messages = [] + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(all_messages, f, ensure_ascii=False, indent=2) + print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") + else: + for m in all_messages: + sender = m.get('senderNick') or m.get('sender', '未知') + text = m.get('text') or m.get('content', '') + time_str = m.get('createAt') or m.get('time', '') + print(f" [{time_str}] {sender}: {text[:80]}") + print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") + + +if __name__ == '__main__': + main() diff --git a/skills/scripts/contact_dept_members.py b/skills/mono/scripts/contact_dept_members.py similarity index 100% rename from skills/scripts/contact_dept_members.py rename to skills/mono/scripts/contact_dept_members.py diff --git a/skills/mono/scripts/doc_create_and_write.py b/skills/mono/scripts/doc_create_and_write.py new file mode 100644 index 00000000..367e4b93 --- /dev/null +++ b/skills/mono/scripts/doc_create_and_write.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +在指定目录创建文档并写入 Markdown 内容(一键完成) + +用法: + python doc_create_and_write.py \ + --name "项目周报" \ + --content "# 本周总结\n\n## 完成事项\n- 任务A" + + python doc_create_and_write.py \ + --name "会议纪要" \ + --content-file notes.md + + python doc_create_and_write.py \ + --name "知识库文档" --content "# 内容" --folder FOLDER_ID + + python doc_create_and_write.py --name "test" --content "hello" --dry-run +""" + +import sys +import json +import time +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def run_dws_with_retry( + args: List[str], + dry_run: bool = False, + max_retries: int = 3, + retry_delay: float = 1.0, +) -> Optional[Any]: + """带重试机制的 dws 命令执行""" + last_error = None + for attempt in range(1, max_retries + 1): + result = run_dws(args, dry_run=dry_run) + if result is not None: + return result + if attempt < max_retries: + print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...") + time.sleep(retry_delay) + retry_delay *= 1.5 # 指数退避 + return None + + +def main(): + parser = argparse.ArgumentParser( + description='创建文档并写入内容' + ) + parser.add_argument('--name', required=True, help='文档名称') + parser.add_argument('--content', default='', help='Markdown 内容') + parser.add_argument('--content-file', default='', help='内容文件') + parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL') + parser.add_argument('--workspace', default='', help='目标知识库 ID') + parser.add_argument( + '--mode', default='append', choices=['overwrite', 'append'], + help='写入模式: overwrite=覆盖, append=追加 (默认 append)', + ) + parser.add_argument( + '--max-retries', type=int, default=3, + help='每块写入失败时的最大重试次数 (默认 3)', + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + content = args.content + if args.content_file: + p = Path(args.content_file) + if not p.exists(): + print(f"错误:文件不存在: {p}") + sys.exit(1) + content = p.read_text(encoding='utf-8') + if not content: + print('错误:需要 --content 或 --content-file') + sys.exit(1) + chunk_size = 30000 + + create_args = ['doc', 'create', '--name', args.name, '--format', 'json'] + if args.folder: + create_args.extend(['--folder', args.folder]) + if args.workspace: + create_args.extend(['--workspace', args.workspace]) + + print(f'\n📝 创建文档: {args.name}') + create_data = run_dws(create_args, dry_run=args.dry_run) + + node_id = None + if not args.dry_run: + if not create_data: + sys.exit(1) + node_id = (create_data.get('nodeId') + or create_data.get('dentryUuid') + or create_data.get('id', '')) + print(f" ✓ 文档已创建 (ID: {node_id})") + + if len(content) <= chunk_size: + mode_label = '追加' if args.mode == 'append' else '覆盖' + print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...') + write_data = run_dws([ + 'doc', 'update', + '--node', node_id or '', + '--content', content, + '--mode', args.mode, + '--format', 'json', + ], dry_run=args.dry_run) + if write_data: + print(f" ✓ 内容已写入 ({len(content)} 字符)") + else: + chunks = [] + pos = 0 + while pos < len(content): + end = min(pos + chunk_size, len(content)) + if end < len(content): + newline_pos = content.rfind('\n', pos, end) + if newline_pos > pos: + end = newline_pos + 1 + chunks.append(content[pos:end]) + pos = end + + total_chunks = len(chunks) + print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...') + + success_chunks = 0 + for idx, chunk in enumerate(chunks): + chunk_mode = args.mode if idx == 0 else 'append' + write_data = run_dws_with_retry( + [ + 'doc', 'update', + '--node', node_id or '', + '--content', chunk, + '--mode', chunk_mode, + '--format', 'json', + ], + dry_run=args.dry_run, + max_retries=args.max_retries, + ) + if write_data: + print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)") + success_chunks += 1 + elif not args.dry_run: + # 写入失败,报告部分写入状态 + print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)") + print(f"\n⚠️ 文档处于部分写入状态:") + print(f" - 文档 ID: {node_id}") + print(f" - 已写入: {success_chunks}/{total_chunks} 块") + print(f" - 失败位置: 第 {idx + 1} 块") + if args.mode == 'overwrite': + print(f" - 模式: 覆盖模式,文档可能包含不完整内容") + print(f" - 建议: 手动检查文档内容,或删除后重新创建") + else: + print(f" - 模式: 追加模式,已写入内容已保存") + print(f" - 建议: 可手动补充剩余内容,或重新运行脚本") + sys.exit(1) + print('\n✅ 完成!') + + +if __name__ == '__main__': + main() diff --git a/skills/scripts/import_records.py b/skills/mono/scripts/import_records.py similarity index 100% rename from skills/scripts/import_records.py rename to skills/mono/scripts/import_records.py diff --git a/skills/mono/scripts/minutes_extract_todos.py b/skills/mono/scripts/minutes_extract_todos.py new file mode 100644 index 00000000..7ca3d651 --- /dev/null +++ b/skills/mono/scripts/minutes_extract_todos.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +从听记中提取所有待办事项并汇总 + +用法: + python minutes_extract_todos.py # 最近 5 条听记 + python minutes_extract_todos.py --max 10 # 最近 10 条 + python minutes_extract_todos.py --id # 指定听记 + python minutes_extract_todos.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + +_scripts_dir = Path(__file__).resolve().parent +if str(_scripts_dir) not in sys.path: + sys.path.insert(0, str(_scripts_dir)) + +from minutes_list_parse import uuid_title_pairs_from_payload + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description='从听记中提取待办事项' + ) + parser.add_argument('--max', type=int, default=5) + parser.add_argument('--id', default='', help='指定听记 UUID') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + uuids_with_titles = [] + if args.id: + uuids_with_titles = [(args.id, args.id)] + else: + print('🎙️ 获取听记列表...') + data = run_dws([ + 'minutes', 'list', 'mine', + '--max', str(args.max), + '--format', 'json', + ], dry_run=args.dry_run) + if args.dry_run: + run_dws([ + 'minutes', 'get', 'todos', + '--id', '', '--format', 'json', + ], dry_run=True) + return + if not data: + return + uuids_with_titles = uuid_title_pairs_from_payload(data) + + all_todos = [] + for uuid, title in uuids_with_titles: + print(f" 提取待办: {title}") + todos_data = run_dws([ + 'minutes', 'get', 'todos', + '--id', uuid, '--format', 'json', + ]) + if not todos_data: + continue + if isinstance(todos_data, list): + items = todos_data + elif isinstance(todos_data, dict): + inner = todos_data.get('result', todos_data) + if isinstance(inner, dict): + items = inner.get('todos', []) + elif isinstance(inner, list): + items = inner + else: + items = [] + else: + items = [] + for t in items: + if isinstance(t, dict): + t['_source'] = title + all_todos.extend(items) + + print(f"\n📋 听记待办汇总") + print('=' * 50) + + if not all_todos: + print(' ✅ 暂无待办事项') + return + + for t in all_todos: + if not isinstance(t, dict): + print(f" • {t!r}") + continue + content = (t.get('content') or t.get('text') + or t.get('title', '')) + source = t.get('_source', '') + print(f" • {content}") + if source: + print(f" 来自: {source}") + + print(f"\n合计: {len(all_todos)} 条待办") + + +if __name__ == '__main__': + main() diff --git a/skills/mono/scripts/minutes_recent_summary.py b/skills/mono/scripts/minutes_recent_summary.py new file mode 100644 index 00000000..24e8ed44 --- /dev/null +++ b/skills/mono/scripts/minutes_recent_summary.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +获取最近 N 条听记的 AI 摘要并合并输出 + +用法: + python minutes_recent_summary.py # 最近 5 条 + python minutes_recent_summary.py --max 10 # 最近 10 条 + python minutes_recent_summary.py --output summary.md + python minutes_recent_summary.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + +_scripts_dir = Path(__file__).resolve().parent +if str(_scripts_dir) not in sys.path: + sys.path.insert(0, str(_scripts_dir)) + +from minutes_list_parse import uuid_title_pairs_from_payload + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description='获取最近听记的 AI 摘要' + ) + parser.add_argument( + '--max', type=int, default=5, help='获取条数 (默认 5)' + ) + parser.add_argument( + '--output', default='', help='输出到 Markdown 文件' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print('🎙️ 获取听记列表...') + list_data = run_dws([ + 'minutes', 'list', 'mine', + '--max', str(args.max), + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + run_dws([ + 'minutes', 'get', 'summary', + '--id', '', '--format', 'json', + ], dry_run=True) + return + + if not list_data: + print('未找到听记') + return + + pairs = uuid_title_pairs_from_payload(list_data) + if not pairs: + print('暂无听记') + return + + output_lines = [f"# 最近 {len(pairs)} 条听记摘要\n"] + for i, (uuid, title) in enumerate(pairs, 1): + print(f" [{i}/{len(pairs)}] 获取摘要: {title}") + + summary_data = run_dws([ + 'minutes', 'get', 'summary', + '--id', uuid, '--format', 'json', + ]) + summary_text = '' + if summary_data: + if isinstance(summary_data, str): + summary_text = summary_data + elif isinstance(summary_data, dict): + summary_text = (summary_data.get('summary') + or summary_data.get('content') + or json.dumps(summary_data, + ensure_ascii=False)) + + output_lines.append(f"## {i}. {title}\n") + if summary_text: + output_lines.append(f"{summary_text}\n") + else: + output_lines.append("(暂无摘要)\n") + + full_output = '\n'.join(output_lines) + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + f.write(full_output) + print(f"\n✓ 已输出到 {args.output}") + else: + print('\n' + full_output) + + +if __name__ == '__main__': + main() diff --git a/skills/scripts/report_inbox_today.py b/skills/mono/scripts/report_inbox_today.py similarity index 100% rename from skills/scripts/report_inbox_today.py rename to skills/mono/scripts/report_inbox_today.py diff --git a/skills/scripts/todo_batch_create.py b/skills/mono/scripts/todo_batch_create.py similarity index 100% rename from skills/scripts/todo_batch_create.py rename to skills/mono/scripts/todo_batch_create.py diff --git a/skills/scripts/todo_daily_summary.py b/skills/mono/scripts/todo_daily_summary.py similarity index 100% rename from skills/scripts/todo_daily_summary.py rename to skills/mono/scripts/todo_daily_summary.py diff --git a/skills/scripts/todo_overdue_check.py b/skills/mono/scripts/todo_overdue_check.py similarity index 100% rename from skills/scripts/todo_overdue_check.py rename to skills/mono/scripts/todo_overdue_check.py diff --git a/skills/scripts/upload_attachment.py b/skills/mono/scripts/upload_attachment.py similarity index 100% rename from skills/scripts/upload_attachment.py rename to skills/mono/scripts/upload_attachment.py diff --git a/skills/multi/dingtalk-aiapp/SKILL.md b/skills/multi/dingtalk-aiapp/SKILL.md new file mode 100644 index 00000000..c9162821 --- /dev/null +++ b/skills/multi/dingtalk-aiapp/SKILL.md @@ -0,0 +1,35 @@ +--- +name: dingtalk-aiapp +description: 钉钉 AI 应用生成。Use when 用户说 创建应用/生成系统/做工具/管理后台/工作台应用/表单系统/业务原型/页面/平台。强制触发:用户提到「应用 / 系统 / 平台 / 工具 / 后台 / 页面 / 原型」时优先匹配此 skill。Distinct from dingtalk-workbench(工作台应用列表)、dingtalk-wiki(知识库)。命令前缀:dws aiapp。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 AI 应用 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[aiapp.md](references/aiapp.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "创建一个 XX 应用 / 系统 / 工具" | `python scripts/aiapp_create_and_poll.py --prompt "<用户原始需求>"`(自动轮询进度) | +| "查 AI 应用 / 修改已有应用" | 见 [aiapp.md](references/aiapp.md) | + +## 跨产品协作 + +- 已存在的工作台应用列表 → 切到 `dingtalk-workbench` diff --git a/skills/multi/dingtalk-aiapp/references/aiapp.md b/skills/multi/dingtalk-aiapp/references/aiapp.md new file mode 100644 index 00000000..a7328ede --- /dev/null +++ b/skills/multi/dingtalk-aiapp/references/aiapp.md @@ -0,0 +1,420 @@ +# AI应用 (aiapp) 命令参考 + +## 强制触发规则 + +以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”: + +- 创建一个新应用 +- 创建一个新系统 +- 生成一个业务工具 +- 按需求生成一个管理后台、工作台应用、表单系统、业务原型 +- 修改一个已经生成过的应用 + +典型触发词包括但不限于: + +- 应用、系统、平台、工具、后台、页面、原型、工作台应用 +- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具 +- 计算器、待办工具、表单应用、信息收集应用、报表应用 +- “帮我做一个…” +- “帮我创建一个…” +- “按这个需求生成一个…” +- “把这个需求做成应用” + +典型例子: + +- “帮我创建个简易的计算器应用” +- “做一个仓库管理系统” +- “生成一个客户管理工具” +- “按这个 PRD 创建一个钉钉应用” +- “修改刚才那个应用,把首页改得更简洁” + +不要误路由到其他产品: + +- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用” +- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用” +- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统” + +只有在以下场景不要走 `aiapp`: + +- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息 +- 用户不是要“生成应用”,而是要“修改已有产品对象” + +## 命令总览 + +### 创建 AI 应用 +``` +Usage: + dws aiapp create [flags] +Example: + dws aiapp create --prompt "创建一个天气查询应用" + dws aiapp create --prompt "翻译应用" --skills s1,s2 + dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' +Flags: + --prompt string 创建 AI 应用的 prompt (必填) + --skills string 技能 ID 列表,逗号分隔 + --attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入 +``` + +### 查询 AI 应用 +``` +Usage: + dws aiapp query [flags] +Example: + dws aiapp query --task-id +Flags: + --task-id string AI 应用任务 ID (必填) +``` + +### 修改 AI 应用 +``` +Usage: + dws aiapp modify [flags] +Example: + dws aiapp modify --prompt "改为翻译应用" --thread-id + dws aiapp modify --prompt "新描述" --thread-id --skills s1,s2 + dws aiapp modify --prompt "根据新图片优化首页视觉风格" --thread-id --attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' +Flags: + --prompt string 新的 prompt (必填) + --skills string 技能 ID 列表,逗号分隔 + --thread-id string threadId (必填) + --attachments string 附件对象数组的 JSON 字符串;当前 modify 仅支持图片附件,不支持 Excel +``` + +## 三个动作的关系 + +`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期: + +1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId` +2. `query` 用来跟踪这次任务是否完成,以及读取生成结果 +3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改 + +可以把它理解为: + +- `create` = 新建一个应用会话 +- `query` = 查看这个会话对应任务的执行状态和结果 +- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化 + +关键标识符的关系: + +- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用 +- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用 +- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId` +- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId` + +因此推荐把它们看成一条闭环链路,而不是三个平级命令: + +`create -> query -> modify -> query -> modify ...` + +## `attachments` 的含义 + +`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。 + +它适合这些场景: + +- 根据 Excel 创建应用 +- 根据需求文档、表格、截图继续完善应用 +- 在修改已有应用时补充新的结构化材料 + +可以把它理解为: + +- `prompt` 描述“希望系统做什么” +- `attachments` 提供“系统可直接参考的文件材料” + +### 正确心智 + +- `attachments` 是 `create` 或 `modify` 的补充输入,不是独立动作 +- 即使提供了附件,主动作仍然是 `aiapp create` 或 `aiapp modify` +- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入 + +### 数据结构 + +`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递: + +```json +[ + { + "name": "warehouse.xlsx", + "type": "excel", + "url": "https://tmp/warehouse.xlsx", + "size": 102400 + } +] +``` + +每个附件对象当前可用字段: + +- `name`:文件名 +- `type`:文件类型或 MIME 类型 +- `url`:可直接访问的临时下载地址 +- `size`:文件大小,可选 + +如果要传多个附件,就在数组中放多个对象。 + +### Excel 场景 + +如果用户上传了 Excel,通常应该: + +- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”或“根据附件继续修改应用” +- 同时把 Excel 通过 `attachments` 传给 `aiapp` + +这类需求都应优先视为 `aiapp`: + +- “根据这个 Excel 建一个库存管理应用” +- “把附件里的表格做成一个 CRM 应用” +- “按这个 Excel 继续补充刚才那个应用” + +### 使用规则 + +- `create` 可以带图片、Excel 等附件 +- `modify` 当前只建议带图片附件,不支持 Excel +- `query` 不使用 `attachments` +- 附件应按对象数组传递,而不是传单个 ID 或随意文本 +- 如果用户提供本地文件 + 任意意图 → 先上传文件获取 URL,再执行对应命令 +- 每个附件对象通常至少应包含可访问的临时下载地址 `url` +- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判 + +### 示例 + +```bash +# 根据 Excel 创建应用 +dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \ + --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \ + --format json + +# 在已有应用上继续结合图片修改 +dws aiapp modify --prompt "根据新图片优化首页布局和视觉风格" \ + --thread-id \ + --attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \ + --format json +``` + +## `--skills` 的含义 + +`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。 + +它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是: + +- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入 +- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入 + +可以把它理解为: + +- `prompt` 描述“你想做什么应用” +- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范” + +因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。 + +### 正确心智 + +- `aiapp` 是主动作 +- `--skills` 是主动作的附加参数 +- 用户是否挂载官方技能,会影响应用生成或修改效果 +- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify` + +### 常见误区 + +不要把以下场景理解错: + +- 用户说“创建一个应用,并遵循设计规范” + - 正确做法:调用 `aiapp create ... --skills ` + - 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作 + +- 用户说“继续修改刚才那个应用,并带上设计规范” + - 正确做法:调用 `aiapp modify ... --thread-id --skills ` + - 不正确做法:先执行设计规范技能,再单独调用 `modify` + +- 用户只是在描述“希望页面更漂亮、更标准” + - 如果已知对应的官方技能 ID,可以通过 `--skills` 传入 + - 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt` + +### 什么时候传 `--skills` + +适合传 `--skills` 的情况: + +- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板 +- 当前上下文里已经拿到了可用的官方技能 ID/Code +- 需要在创建或修改时显式挂载这些技能 + +不适合传 `--skills` 的情况: + +- 没有明确的技能 ID +- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂 +- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力 + +### 使用规则 + +- `create` 和 `modify` 都可以带 `--skills` +- `query` 不使用 `--skills` +- `--skills` 里的值必须是技能 ID,多个值用逗号分隔 +- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回 + +### 示例 + +```bash +# 创建应用,并挂载一个钉钉设计规范技能 +dws aiapp create --prompt "创建一个仓库管理应用" \ + --skills dingtalk_design_spec --format json + +# 修改已有应用,并继续带上官方技能 +dws aiapp modify --prompt "增加个库存大盘图表页" \ + --thread-id \ + --skills dingtalk_design_spec --format json +``` + +## 意图判断 + +用户说以下任何一种,都走 `create`: + +- “做个应用 / 创建应用 / 生成应用 / 搭个应用” +- “做个系统 / 平台 / 后台 / 工具 / 页面” +- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用” +- “按这段需求生成一个钉钉应用” + +用户说以下任何一种,都走 `query`: + +- “查看应用状态” +- “这个应用创建好了没” +- “查一下 taskId 对应的进度” +- “继续轮询应用创建进展” + +用户说以下任何一种,都走 `modify`: + +- “修改应用” +- “更新刚才那个应用” +- “基于这个 thread/task 继续改” +- “把首页改一下 / 把功能补上 / 换个风格” + +## 核心工作流 + +```bash +# 1. 创建 AI 应用 — 提取 taskId 和 threadId +dws aiapp create --prompt "创建一个天气查询应用" --format json + +# 2. 查询应用状态 +dws aiapp query --task-id --format json + +# 3. 修改应用 +dws aiapp modify --prompt "新描述" --thread-id \ + --skills skill1,skill2 --format json +``` + +补充理解: + +- 如果用户还没有现成应用,先走 `create` +- 如果用户刚发起了创建,通常先走 `query` 看是否完成 +- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify` +- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query` +- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求 +- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加文件输入 +- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要用 Excel 附件 +- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力 + +推荐决策顺序: + +1. 先判断用户是不是要“生成一个新的应用/系统” +2. 如果是,直接用 `aiapp create` +3. 如果用户是在已有应用基础上继续改,用 `aiapp modify` +4. 如果只是查进度或结果,用 `aiapp query` + +## 查询与轮询预期 + +`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。 + +- 简单应用通常需要几分钟 +- 复杂应用可能持续十几分钟到几十分钟 + +因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。 + +### 正确的轮询策略 + +- 不要每 5-10 秒高频轮询 +- 建议以 30 秒为默认轮询间隔 +- 如果任务已经明确进入长流程,可进一步放宽到 60 秒 +- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询 + +### 代理行为约束 + +当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。 + +- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度 +- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询 +- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务 + +### 定时任务规则 + +如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件: + +- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务 +- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务 +- 不要在已经拿到目标结果后继续保留定时任务 +- 不要创建“只会查询、不会自终止”的定时任务 + +换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询 + +### 如何理解 `query` 返回 + +`query` 的返回里,除了总状态外,还可能包含进度信息,例如: + +- 当前正在执行哪个步骤 +- 已完成多少步骤 +- 最近一次活动时间 +- 当前步骤列表 +- 当前 thread 的访问链接 + +因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看: + +- `updatedAt` / `lastActivityAt` +- `progress.summary` +- `progress.currentStep` +- `progress.steps` +- `threadViewUrl` + +只要这些字段仍在变化,通常就说明任务仍在正常推进。 + +### `threadViewUrl` 与 `appPreviewUrl` 的区别 + +`query` 或 `create` 的返回里,可能会有两个不同的链接: + +- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态 +- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用 + +不要把这两个链接混用: + +- 任务还在 `queued/running` 时,优先使用 `threadViewUrl` +- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl` + +### 何时停止轮询 + +- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息 +- 当 `status = failed` 时,停止轮询,并向用户报告失败 +- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论 +- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理 + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 | +| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify | + +## 注意事项 + +- `create` 通过自然语言 prompt 描述应用功能,系统自动生成 +- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现 +- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp` +- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]` +- `create` 可使用 Excel 附件;`modify` 当前仅支持图片附件,不支持 Excel +- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力 +- `modify` 需要 `--thread-id` 来标识修改哪个会话线程 +- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl` +- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl` +- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询 +- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪 +- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务 +- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [aiapp_create_and_poll.py](../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` | diff --git a/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py b/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py new file mode 100644 index 00000000..663184a6 --- /dev/null +++ b/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +创建 AI 应用并自动轮询等待完成 + +用法: + python aiapp_create_and_poll.py \ + --prompt "创建一个仓库管理应用" + + python aiapp_create_and_poll.py \ + --prompt "生成客户管理 CRM" \ + --skills skill1,skill2 \ + --interval 30 \ + --timeout 600 + + python aiapp_create_and_poll.py --dry-run --prompt "test" +""" + +import sys +import json +import subprocess +import argparse +import time +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def main(): + parser = argparse.ArgumentParser( + description='创建 AI 应用并轮询等待完成' + ) + parser.add_argument( + '--prompt', required=True, help='应用描述' + ) + parser.add_argument( + '--skills', default='', help='技能 ID 列表' + ) + parser.add_argument( + '--interval', type=int, default=30, + help='轮询间隔秒 (默认 30)', + ) + parser.add_argument( + '--timeout', type=int, default=600, + help='最大等待秒 (默认 600)', + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print(f'🚀 创建 AI 应用...') + print(f' Prompt: {args.prompt}') + cmd_args = [ + 'aiapp', 'create', + '--prompt', args.prompt, + '--format', 'json', + ] + if args.skills: + cmd_args.extend(['--skills', args.skills]) + + create_data = run_dws(cmd_args, dry_run=args.dry_run) + if args.dry_run: + run_dws([ + 'aiapp', 'query', + '--task-id', '', + '--format', 'json', + ], dry_run=True) + return + + if not create_data: + sys.exit(1) + + task_id = create_data.get('taskId') or create_data.get('id', '') + thread_id = create_data.get('threadId', '') + print(f" ✓ 任务已创建") + print(f" taskId: {task_id}") + print(f" threadId: {thread_id}") + + print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, ' + f'超时 {args.timeout}s)...') + elapsed = 0 + while elapsed < args.timeout: + time.sleep(args.interval) + elapsed += args.interval + + query_data = run_dws([ + 'aiapp', 'query', + '--task-id', task_id, + '--format', 'json', + ]) + if not query_data: + print(f" [{elapsed}s] ⚠ 查询失败,继续等待...") + continue + + status = (query_data.get('status') + or query_data.get('state', 'unknown')) + progress = query_data.get('progress', {}) + step = '' + if isinstance(progress, dict): + step = progress.get('currentStep', '') + + if status == 'succeeded': + print(f" [{elapsed}s] ✅ 应用创建成功!") + if thread_id: + print(f" threadId: {thread_id}") + return + elif status == 'failed': + print(f" [{elapsed}s] ❌ 创建失败") + sys.exit(1) + else: + info = f" [{elapsed}s] ⏳ {status}" + if step: + info += f" ({step})" + print(info) + + print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行") + print(f" 可手动查询: dws aiapp query --task-id {task_id}") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-aisearch/SKILL.md b/skills/multi/dingtalk-aisearch/SKILL.md new file mode 100644 index 00000000..5282d9b5 --- /dev/null +++ b/skills/multi/dingtalk-aisearch/SKILL.md @@ -0,0 +1,48 @@ +--- +name: dingtalk-aisearch +description: AI 搜问 - 搜人首选入口(按姓名/部门/职位/职责/上下级/手机号/工号维度)。Use when 用户说 找同事/找人/谁负责XX/XX的负责人是谁/查上级/查下级/团队成员/XX工号是谁/XX手机号。Distinct from dingtalk-contact(精确按 userId 查详情)。命令前缀:dws aisearch。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 AI 搜问(搜人)Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[aisearch.md](references/aisearch.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "找张三 / 张三是谁" | `dws aisearch person --keyword "张三" --dimension name` | +| "谁负责 XX / XX 负责人是谁" | `dws aisearch person --keyword "" --dimension duty` | +| "张三的上级 / 下级" | `dws aisearch person --keyword "张三" --dimension supervisor`(或 `subordinate`) | +| "X 部门有哪些人" | `dws aisearch person --keyword "<部门>" --dimension department` | +| "工号 12345 是谁 / 138xxxx 手机号是谁" | `dws aisearch person --keyword "<工号>" --dimension jobNumber` / `dws aisearch person --keyword "<手机号>" --dimension phone` | + +## 评测高频硬约束 + +- 搜索目标必须完整保真:姓名、工号、手机号、部门名按用户原文完整传入 `--keyword`,严禁自行截断、拆字、改昵称或扩展同音字。 +- 首次未命中时最多换维度重试一次(如 name → department/jobNumber/phone),仍必须保留完整目标值;不要用半截姓名扩大搜索。 +- 找到候选后,如用户要邮箱、部门、职位、主管等详情,必须切到 `dingtalk-contact` 执行 `contact user get --ids --format json` 补全。 +- 多候选且无法唯一判断时输出候选并询问;不要默认取第一个,也不要编造未返回的人员信息。 +- 所有 `dws aisearch` 命令加 `--format json`。 + +## 跨产品协作 + +- 拿到 userId 后查详情 / 部门 → 切到 `dingtalk-contact` +- 拿到 userId 发消息 → 切到 `dingtalk-chat` +- 拿到 userId 发 DING → 切到 `dingtalk-ding` diff --git a/skills/multi/dingtalk-aisearch/references/aisearch.md b/skills/multi/dingtalk-aisearch/references/aisearch.md new file mode 100644 index 00000000..051ba9cb --- /dev/null +++ b/skills/multi/dingtalk-aisearch/references/aisearch.md @@ -0,0 +1,74 @@ +# aisearch — AI 搜问 + +> `aisearch` 模块**规范子命令是 `person`**,所有调用请使用 `dws aisearch person --keyword "..."`。 +> +> **容错说明**(无需主动使用):CLI 兼容下列 alias 兜底,模型偶尔写 `search` / `find` / `query` / `contact` / `people` 等也能跑通——但**输出和文档以 `person` 为准**。 +> +> keyword flag 同样兼容 `--name` / `--q` / `--query` / `--text` 兜底,规范用法是 `--keyword`。 + +## 企业人员搜索 + +通过关键词搜索企业内人员信息,支持按维度筛选。 + +``` +Usage: + dws aisearch person [flags] +Example: + dws aisearch person --keyword "张三" --dimension name --format json + dws aisearch person --keyword "产品部" --dimension department --format json + dws aisearch person --keyword "五道" --dimension supervisor --format json + dws aisearch person --keyword "AI搜问" --dimension duty --format json + dws aisearch person --keyword "李四" --dimension name,department --format json + dws aisearch person --keyword "13800138000" --dimension phone --format json + dws aisearch person --keyword "W12345" --dimension jobNumber --format json +Flags: + --keyword string 搜索关键词 (必填,如人名、技能关键词等) + --dimension string 查询维度,多个用逗号分隔 (默认 "all") +``` + +### dimension 可选值 + +| 值 | 含义 | 触发词 | +|----|------|--------| +| `all` | 全部维度(默认) | — | +| `name` | 姓名 | "叫什么"、"是谁" | +| `department` | 部门 | "部门"、"团队"、"哪个部门" | +| `position` | 职位 | "职位"、"岗位"、"职级" | +| `duty` | 职责/技能 | "负责什么"、"职责"、"技能"、"负责人" | +| `supervisor` | 上级 | "上级"、"领导"、"主管" | +| `subordinate` | 下级 | "下级"、"下属"、"团队成员" | +| `phone` | 手机号 | "手机号是多少"、"电话"、"联系方式" | +| `jobNumber` | 工号 | "工号"、"工号是多少"、"员工编号" | + +### keyword 提取规则 + +仅填入实际的搜索目标(人名、技能关键词等),不包含查询维度词。维度词必须映射到 `--dimension`: + +| 用户说 | keyword | dimension | +|--------|---------|-----------| +| "五道的上级是谁" | 五道 | supervisor | +| "张三负责什么" | 张三 | duty | +| "AI搜问的负责人是谁" | AI搜问 | duty | +| "产品部有谁" | 产品部 | department | +| "李四是哪个部门的" | 李四 | department | +| "13800138000是谁" | 13800138000 | phone | +| "工号W12345是谁" | W12345 | jobNumber | + +--- + +## 意图判断 + +- 用户说"搜人/找人/谁负责/上级是谁/哪个部门的人" → `aisearch person` +- 用户说"搜同事/查部门/查通讯录" → `contact`(通讯录) + +**关键区分**:`aisearch person`(AI 语义搜人,支持按维度筛选:职责/手机号/上级/下级等)vs `contact`(通讯录精确查询:userId/部门成员列表) + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) | + +## 重名消歧 + +> **CAUTION:** 多人同名时禁止默认选第一个 — 须追加 `contact user get --ids userId1,userId2,...` 获取部门/职位后请用户确认。详见 `dingtalk-contact/references/08-directory.md`「多命中」。 diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md new file mode 100644 index 00000000..f4458531 --- /dev/null +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -0,0 +1,62 @@ +--- +name: dingtalk-aitable +description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。Distinct from 主 dws skill 的 dws sheet(电子表格/单元格读写/公式)、dws doc(文档编辑)。命令前缀:dws aitable。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 AI 表格 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[aitable.md](references/aitable.md);复杂命令按需加载 `references/aitable/*.md`;剧本:[06-data-analytics.md](references/06-data-analytics.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "搜表格 / 找一个 base" | `dws aitable base search --query "<名>"` | +| "创建 AI 表格 / 多维表" | `dws aitable base create --name "<名称>" [--template-id ]` | +| "查数据表 / 建数据表" | `dws aitable table get --base-id ` / `dws aitable table create --base-id --name "<表名>" --fields '[...]'` | +| "查字段 / 字段类型" | `dws aitable field get --base-id --table-id ` | +| "查记录 / 搜索记录" | `dws aitable record query --base-id --table-id [--filters '...']` | +| "写记录 / 更新记录 / 删除记录" | `dws aitable record create/update/delete --base-id --table-id ...` | +| "筛选 / 排序 / 公式 / 跨表引用" | 先读 `references/aitable/aitable-filter-sort.md` / `aitable-formula-guide.md` | +| "批量导入 JSON / CSV" | `python scripts/import_records.py data.csv\|data.json` | +| "批量加字段" | `python scripts/bulk_add_fields.py --base-id --table-id --fields fields.json` | +| "导入 / 导出表格" | 先读 `references/aitable/aitable-export-import.md`;导出优先 `python scripts/aitable_export_via_task.py --scope table --table-id ` | +| "仪表盘 / 图表" | 先读 `references/aitable/aitable-dashboard-chart.md` | +| "上传附件到记录" | 先读 `references/aitable/aitable-attachment.md`;可用 `python scripts/upload_attachment.py --base-id --file ` | + +## 危险操作 + +`base delete` / `table delete` / `field delete` / `record delete` 不可逆,必须先向用户确认再加 `--yes`。 + +## 评测高频硬约束 + +- 创建/改字段/写记录是多轮连续任务时,不能在"让我执行/先获取 ID"后停下;必须实际调用对应 `dws aitable` 命令并验证结果。 +- 字段重命名使用 `dws aitable field update --base-id --table-id --field-id --name "<新名称>" --format json`;先 `field get` 找真实 `fieldId`,不要猜字段名能直接更新。 +- 写记录前必须 `field get` 获取 `fieldId` 与类型;`record create/update` 的 `cells` key 用 `fieldId`,不是字段中文名。长 JSON 使用 `--records-file`。 +- 表或字段创建返回名称被系统自动加后缀时,后续必须使用返回的真实 `tableId`/`fieldId`,不要继续按原名称猜。 +- `record update/delete` 先 `record query/list` 定位 `recordId`;删除必须确认,普通新增/更新按用户明确要求可直接执行后读回验证。 +- `record query/create/update/delete`、`field create`、导入导出、图表和附件场景必须先读对应 `references/aitable/*.md`,不要凭旧单文件参数猜 flag。 + +## 字段类型规则 + +详见本 skill 的 [field-rules.md](references/field-rules.md)。 + +## 跨产品协作 + +- 单元格 / 工作表 / 公式 → 走主 `dws` skill 的 `sheet` 产品路由(`dws sheet`) diff --git a/skills/multi/dingtalk-aitable/references/06-data-analytics.md b/skills/multi/dingtalk-aitable/references/06-data-analytics.md new file mode 100644 index 00000000..5e9504cd --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/06-data-analytics.md @@ -0,0 +1,11 @@ +# 数据分析 + +> 本场景所有 recipe 均为 full。 + +| Recipe | 行动指南(固定路线) | +|--------|-------------------| +| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable field get --base-id --table-id ` → 取 `fieldId`
3. `aitable record query --base-id --table-id ` → 取记录(分页)
4. 总结数据 | +| generate-data-report | 1. 同 read-aitable 步骤 1-3
2. 按「多源并行采集」(见 root `dws` skill `references/best_practices/_common/conventions.md`)执行 → 补充背景
3. `doc create --name "<报告名>" --content "<分析报告>"` | +| create-aitable-record | **批量导入优先**:`python scripts/import_records.py data.csv\|data.json [batch_size]`(自动分批创建)
单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable field get --base-id --table-id ` → 取 `fieldId` 与类型
3. `aitable record create --base-id --table-id --records '[{"cells":{"":"值"}}]'` | +| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable record query --base-id --table-id ` → 取 `recordId`,**先展示让用户确认**
3. `aitable record update --base-id --table-id --records '[{"recordId":"","cells":{...}}]'` | +| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`
2. 用户选定
3. `aitable base create --name "<表格名>" --template-id ` → 取 `baseId` | diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md new file mode 100644 index 00000000..334496d4 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -0,0 +1,203 @@ +# AI表格 (aitable) 命令参考 + +> **渐进式文档**:本文件为路由层(索引 + 意图判断),各命令的详细参数、示例和踩坑说明在 [aitable/](./aitable/) 目录下按需加载。 + +## 文档地址 (URI) + +| 资源 | URI 格式 | +|------|----------| +| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | +| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | + +> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。 +> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 root `dws` skill `references/url-patterns.md` 的「alidocs URL 类型探测流程」probe,确认是 `able` 后再按 AI 表格处理。 + +## 命令索引表 + +### base (Base 管理) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` | +| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 | +| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID | +| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId | +| `base update` | 更新 Base 名称 | `--base-id` `--name` | — | +| `base delete` | 删除 Base | `--base-id` | 不可逆 | +| `base get-primary-doc-id` | 获取主键文档 ID | `--base-id` `--table-id` `--record-id` | 文档类型主键字段场景 | +| `base copy` | 复制 Base | `--base-id` | — | + +### table (数据表管理) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 | +| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 | +| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — | +| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 | + +### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 | +| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 | +| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 | +| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 | + +### record (记录管理) + +| 命令 | 用途 | 必读 reference | 路由提醒 | +|------|------|----------------|----------| +| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference | +| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 | +| `record update` | 更新记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;**没有** `--record-id` `--cells` flag | +| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 | + +### view (视图管理) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 | +| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner | +| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 | +| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 | + +> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。 + +### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md) + +| 命令 | 用途 | +|------|------| +| `dashboard get/create/update/delete` | 仪表盘管理 | +| `dashboard config-example` | 查看仪表盘配置模板 | +| `chart get/create/update/delete` | 图表管理 | +| `chart widgets-example` | 查看图表 widgets 配置模板 | + +### export & import → 详见 [aitable-export-import.md](./aitable/aitable-export-import.md) + +| 命令 | 用途 | +|------|------| +| `export data` | 导出数据(异步两阶段轮询) | +| `import upload` | 申请文件导入上传凭证 | +| `import data` | 触发导入 | + +### attachment → 详见 [aitable-attachment.md](./aitable/aitable-attachment.md) + +| 命令 | 用途 | 路由提醒 | +|------|------|----------| +| `attachment upload` | 准备附件上传凭证 | 不要用钉盘 drive 上传! | + +### template (模板搜索) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `template search` | 搜索模板 | `--query` | + +## 意图判断 + +用户说"表格/多维表/AI表格": +- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问) +- 详情 → `base get` +- 创建 → `base create` +- 修改 → `base update` +- 删除 → `base delete` + +用户说"数据表/子表/table": +- 查看 → `table get` +- 创建 → `table create` +- 重命名 → `table update` +- 删除 → `table delete` + +用户说"字段/列/column": +- 查看 → `field get` +- 添加 → `field create`(读 [aitable-field.md](./aitable/aitable-field.md)) +- 修改 → `field update` +- 删除 → `field delete` + +用户说"记录/行/数据/row": +- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md)) +- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md)) +- 修改/更新 → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md)) +- 删除 → `record delete` + +用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md) + +用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md) + +用户说"公式/formula/计算字段/派生指标" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md) + +用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用) + +用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md) + +用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md) + +用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md) + +用户说"模板" → `template search` + +命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md) + +**关键区分**: base=表格文件, table=数据表, field=列, record=行 + +## 核心工作流 + +```bash +# 1. 搜索/列出 Base — 提取 baseId +dws aitable base search --query "项目" --format json + +# 2. 获取 Base 信息 — 提取 tableId +dws aitable base get --base-id --format json + +# 3. 获取表结构 — 提取 fieldId +dws aitable table get --base-id --table-id --format json + +# 4. 查询记录 +dws aitable record query --base-id --table-id --format json + +# 5. 新增记录 (cells 用 fieldId 作 key) +dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldXXX":"值"}}]' --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI | +| `base create` | `baseId` | 后续命令 + 文档 URI | +| `base get` | `tables[].tableId` | --table-id | +| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete | +| `record query` | `recordId` | record update/delete | +| `template search` | `templateId` | base create --template-id,拼接模板预览 URI | + +## URL → baseId 提取 + +用户提供 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 链接时: +1. 提取 `/nodes/` 后的路径段作为 `baseId` +2. 去掉尾部的查询参数(`?` 及其后内容) +3. 传入 `--base-id` 参数 + +> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用; +> 如果是用户直接提供的原始 `alidocs` URL,则先按 root `dws` skill `references/url-patterns.md` 的「alidocs URL 类型探测流程」probe,确认 `extension=able` 后再继续。 + +## 注意事项 + +- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称 +- records 的 cells key 是 fieldId,不是字段名称 +- cells 写入/读取格式见 [aitable-cell-value.md](./aitable/aitable-cell-value.md) +- 最佳实践见 [aitable-best-practices.md](./aitable/aitable-best-practices.md) + +## 自动化脚本 + +| 脚本 | 场景 | +|------|------| +| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 | +| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 | +| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) | +| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 | + +## 相关产品 + +- `dingtalk-doc` — 富文本文档编辑,不是结构化数据表格 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md new file mode 100644 index 00000000..1648fc87 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md @@ -0,0 +1,47 @@ +# attachment — 附件上传 + +> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。 + +## 准备附件上传 + +``` +Usage: + dws aitable attachment upload [flags] +Example: + dws aitable attachment upload --base-id --file-name report.xlsx --size 204800 + dws aitable attachment upload --base-id --file-name photo.png --size 1024 --mime-type image/png +Flags: + --base-id string Base ID (必填) + --file-name string 文件名,必须含扩展名 (必填) + --size int 文件大小(字节),>0 (必填) + --mime-type string MIME type(不传时根据扩展名推断) +``` + +## 附件上传完整流程(推荐:使用脚本,2 步完成) + +```bash +# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT) +python3 scripts/upload_attachment.py /path/to/report.pdf +# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 } + +# 步骤 2: 在 record create/update 中使用 fileToken 写入 +dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json +``` + +> `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。 + +## 手动流程(不使用脚本) + +```bash +# 1. 获取上传凭证 +dws aitable attachment upload --base-id --file-name report.pdf --size 204800 --format json +# → 返回 uploadUrl、fileToken + +# 2. PUT 上传(Content-Type 留空) +curl -X PUT "" -H "Content-Type:" --data-binary @report.pdf + +# 3. 写入记录 +dws aitable record update --base-id --table-id \ + --records '[{"recordId":"recXXX","cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json +``` diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-best-practices.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-best-practices.md new file mode 100644 index 00000000..a851af83 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-best-practices.md @@ -0,0 +1,46 @@ +# AI 表格最佳实践 + +## 1. 字段可写性分类 + +| 字段类型 | 可写 | 正确方式 | +|----------|------|----------| +| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update | +| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) | +| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 | +| 公式/查找引用 | ❌ | 只读,由系统计算 | +| AI 字段 | ❌ | 只读,由 AI 自动计算 | + +## 2. 查询执行契约 + +1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤 +2. **has_more=true 时不能做全局结论** — 数据可能不完整 +3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep +4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId +5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段 + +## 3. 任务选路 + +| 用户诉求 | 优先方案 | 不要误走 | +|---------|----------|----------| +| 查看几条数据 | `record query` | 不要用 `--all` | +| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor | +| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 | +| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 | +| 附件上传 | `attachment upload` + `record update` | 不要在 cells 里伪造附件值 | +| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 | + +## 4. 创建/修改后回读确认 + +执行写操作后,建议立即回读确认结果: + +| 写操作 | 建议回读命令 | 确认内容 | +|--------|-------------|----------| +| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 | +| `field create` | `table get --table-ids ` | 新字段是否出现在字段列表中 | +| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 | + +## 5. AI 字段注意事项 + +- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝 +- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用 +- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`) diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-cell-value.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-cell-value.md new file mode 100644 index 00000000..9f747bf9 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-cell-value.md @@ -0,0 +1,342 @@ +# cells 写入/读取格式规范(cellValue 数据结构) + +> 适用命令:`dws aitable record create --records`、`dws aitable record update --records`、`dws aitable record query` 返回 +> +> 本文件是 DWS AI 表格 cellValue 的 **source of truth**。写入记录时,必须严格按此格式构造 cells 对象。 + +## 顶层规则 + +- cells 的 key **必须是 fieldId**(如 `fldXXX`),不是字段名称 +- fieldId 必须从 `table get` 返回中获取 +- 不同字段类型的 value 格式不同,混用会报错 +- 系统只读字段(creator/lastModifier/createdTime/lastModifiedTime/formula)不可写入 + +## 各字段类型详解 + +### text(文本) + +**写入**:字符串 +```json +{"fldTextId": "这是一段文本"} +``` + +**读取**:字符串 +```json +{"fldTextId": "这是一段文本"} +``` + +--- + +### number(数字) + +**写入**:数字或数字字符串 +```json +{"fldNumId": 123.45} +{"fldNumId": "123.45"} +``` + +**读取**:字符串形式的数字 +```json +{"fldNumId": "123.45"} +``` + +--- + +### singleSelect(单选) + +**写入**:选项名称字符串(推荐),或对象形式 `{id, name}` +```json +{"fldSelectId": "进行中"} +{"fldSelectId": {"id": "opt_xxx", "name": "进行中"}} +``` + +> 写入不存在的选项名称时,系统会自动创建该选项。 +> 对象写入时 id 为准,服务端会校验 id 是否存在。 + +**读取**:对象 `{id, name}` +```json +{"fldSelectId": {"id": "opt_abc123", "name": "进行中"}} +``` + +--- + +### multipleSelect(多选) + +**写入**:选项名称数组(推荐),或对象数组 +```json +{"fldMultiId": ["标签A", "标签B"]} +{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]} +``` + +> 写入时每项需带 id(对象模式)或直接传 name 字符串。不存在的 name 会自动补入选项配置。 + +**读取**:对象数组 +```json +{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]} +``` + +--- + +### date(日期) + +**写入**:日期字符串、RFC3339 字符串、或毫秒时间戳 +```json +{"fldDateId": "2026-03-15"} +{"fldDateId": "2026-03-15 09:00"} +{"fldDateId": "2026-03-15T09:00+08:00"} +``` + +**读取**:RFC3339 字符串 +```json +{"fldDateId": "2026-03-15T09:00:00+08:00"} +``` + +--- + +### currency(货币) + +**写入**:数字(与 number 相同) +```json +{"fldCurrencyId": 99.5} +``` + +**读取**:字符串形式的数字(小数位数取决于 formatter 配置) +```json +{"fldCurrencyId": "99.5"} +``` + +--- + +### progress(进度) + +**写入**:0~1 之间的浮点数(0 表示 0%,1 表示 100%) +```json +{"fldProgressId": 0.75} +``` + +> ⚠️ **常见错误**:写入 75 不会报错,但会被存储为 7500%(因为系统将其理解为 75 倍)。 +> 正确做法:75% 应写入 0.75。API 不会拒绝超出 [0,1] 的值,但显示会异常。 +> 如果字段配置了 `customizeRange`,则按自定义范围传值。 + +**读取**:字符串形式的数字 +```json +{"fldProgressId": "0.75"} +``` + +--- + +### rating(评分) + +**写入**:整数,必须在字段配置的 min~max 范围内 +```json +{"fldRatingId": 4} +``` + +> ⚠️ 超出 max 范围的值(如 max=5 时写入 6)会被服务端拒绝并返回错误。 + +**读取**:数字(字符串形式) +```json +{"fldRatingId": "4"} +``` + +--- + +### checkbox(勾选) + +**写入**:布尔值 +```json +{"fldCheckId": true} +{"fldCheckId": false} +``` + +**读取**:布尔值 +```json +{"fldCheckId": true} +``` + +--- + +### user(人员) + +**写入**:对象数组,每项必须含 `userId` 和 `corpId` +```json +{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]} +``` + +> 单选字段(`multiple=false`)也必须传数组,只是数组长度为 1。 +> 如果目标用户不在当前请求组织内,回退为 `[{"userRef": "ur_0AaZ19"}]`。 + +**读取**:对象数组 +```json +{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]} +``` + +--- + +### department(部门) + +**写入**:对象数组,每项含 `deptId` +```json +{"fldDeptId": [{"deptId": "52528700"}]} +``` + +**读取**:对象数组 +```json +{"fldDeptId": [{"deptId": "52528700"}]} +``` + +--- + +### group(群组) + +**写入**:对象数组,每项含 `cid` +```json +{"fldGroupId": [{"cid": "74577067501"}]} +``` + +> ⚠️ key 是 **`cid`**,不是 `openConversationId` + +**读取**:对象数组 +```json +{"fldGroupId": [{"cid": "74577067501"}]} +``` + +--- + +### url(链接) + +**写入**:对象 `{text, link}` 或纯 URL 字符串 +```json +{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}} +{"fldUrlId": "https://dingtalk.com"} +``` + +> 纯字符串写入时,服务端自动补齐为 `{"text":"原字符串","link":"原字符串"}` + +**读取**:对象 `{text, link}` +```json +{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}} +``` + +--- + +### richText(富文本) + +**写入**:对象 `{markdown: "..."}` +```json +{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}} +``` + +**读取**:对象 `{markdown: "..."}`(有损,颜色/@人等信息可能丢失) +```json +{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}} +``` + +--- + +### attachment(附件) + +**写入**:对象数组,支持 `fileToken` 或 `url` 形式 + +```json +{"fldAttachId": [{"fileToken": "ft_xxx"}]} +{"fldAttachId": [{"url": "https://example.com/file.pdf"}]} +``` + +> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。 +> URL 形式是 best-effort 异步转存,不保证立即可用。 +> 写入会**整体覆盖**原附件列表,不是追加。 + +**读取**:对象数组(含下载链接、文件名、大小) +```json +{"fldAttachId": [{"url": "https://...", "filename": "report.pdf", "size": 204800}]} +``` + +--- + +### telephone / email / barcode / idCard(电话/邮箱/条码/身份证) + +**写入**:字符串 +```json +{"fldPhoneId": "13800138000"} +{"fldEmailId": "test@example.com"} +{"fldBarcodeId": "978-3-16-148410-0"} +{"fldIdCardId": "520402196001067498"} +``` + +> idCard 必须是后端认可的合法身份证号格式 + +**读取**:字符串 +```json +{"fldPhoneId": "13800138000"} +``` + +--- + +### geolocation(地理位置) + +**写入**:对象,包含 `address`、`name`、`location` +```json +{ + "fldGeoId": { + "address": "浙江省杭州市思凯路与爱橙街交叉口东南200米", + "name": "阿里中心·未科D1幢", + "location": ["120.007852", "30.271194"] + } +} +``` + +> `location` 按 **[经度, 纬度]** 传**字符串数组** + +**读取**:对象(含额外的 `fullAddress` 字段,由服务端自动拼接) +```json +{ + "fldGeoId": { + "address": "浙江省杭州市", + "fullAddress": "阿里中心-浙江省杭州市", + "name": "阿里中心", + "location": ["120.007852", "30.271194"] + } +} +``` + +--- + +### unidirectionalLink / bidirectionalLink(关联字段) + +**写入**:对象 `{linkedRecordIds: [...]}` +```json +{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}} +``` + +**读取**:对象 `{linkedRecordIds: [...]}` +```json +{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}} +``` + +--- + +### 只读字段(禁止写入) + +以下字段类型由系统自动填充,`record create/update` 时**禁止传入**: + +| 类型 | 说明 | +|------|------| +| `creator` | 创建人 | +| `lastModifier` | 最后编辑人 | +| `createdTime` | 创建时间 | +| `lastModifiedTime` | 最后编辑时间 | +| `formula` | 公式字段(系统计算) | +| AI 字段 | 由 AI 自动计算 | + +## 常见错误速查 + +| 错误 | 正确做法 | +|------|----------| +| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` | +| progress 写入 `75` | 写入 `0.75`(范围 0~1) | +| attachment 直接传文件路径 | 必须先 upload 获取 fileToken | +| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` | +| group 字段用 `openConversationId` | 用 `cid` | +| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 | +| 对只读字段写入值 | 不传该字段,由系统自动填充 | diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md new file mode 100644 index 00000000..ec96fb4f --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md @@ -0,0 +1,43 @@ +# dashboard & chart — 仪表盘与图表 + +## 建议操作顺序 + +```bash +# 1) 先看配置模板(JSONC) +dws aitable dashboard config-example --format json +dws aitable chart widgets-example --format json + +# 2) 先拿 dashboard,再拿 chart 详情 +dws aitable dashboard get --base-id --dashboard-id --format json +dws aitable chart get --base-id --dashboard-id --chart-id --format json +``` + +## 要点 + +- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用 +- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错 +- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断 + +## dashboard 子命令 + +| 命令 | 用途 | 必填参数 | 说明 | +|------|------|----------|------| +| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — | +| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON | +| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 | +| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — | +| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 | + +## chart 子命令 + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` | +| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` | +| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` | +| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` | +| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 | + +## 配置获取流程 + +创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md new file mode 100644 index 00000000..6331944e --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md @@ -0,0 +1,130 @@ +# AI 表格数据分析 SOP + +> 当用户诉求涉及查询、筛选、排序、统计、Top/Bottom N、分组聚合、判断全局结论时,必须先读本文档再执行。 + +## 1. 查询决策树 + +``` +用户要做什么? +│ +├─ 查看/导出原始记录明细 +│ → record query [--filters] [--sort] [--field-ids] [--limit] +│ +├─ 按条件筛选记录(如"状态=进行中的记录") +│ → record query --filters '{"operator":"and","operands":[...]}' +│ +├─ 取 Top N / Bottom N(如"销售额最高的5条") +│ → record query --sort '[{"fieldId":"xxx","direction":"desc"}]' --limit 5 +│ +├─ 全量统计(如"一共多少条"、"所有记录的总销售额") +│ → record query --all --field-ids <目标字段> +│ → 本地计算 count / sum / avg +│ +├─ 分组统计(如"每个状态各有多少条") +│ → record query --all --field-ids <分组字段>,<度量字段> +│ → 本地按分组字段 groupby 再聚合 +│ +└─ 判断全局结论(如"是否所有记录都满足条件") + → record query --all(或用 --filters 反向筛选不满足的) + → 基于全量结果判断 +``` + +## 2. 核心规则 + +### 2.1 禁止基于默认分页下全局结论 + +`record query` 默认返回 100 条。如果返回 JSON 中 `data.nextCursor` 非空,表示还有后续数据,当前结果**不是全量**。 + +```json +{"data": {"nextCursor": "3hf5MtLbLZ", "records": [...]}} +``` + +- ❌ 错误:只查了默认 100 条就说"共 100 条记录" +- ✅ 正确:使用 `--all` 自动翻页拿全量,再统计 + +### 2.2 能在服务端过滤的,不要拉到本地再过滤 + +| 需求 | 正确做法 | 错误做法 | +|------|---------|---------| +| 筛选"状态=已完成" | `--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","已完成"]}]}'` | `--all` 拉全量再本地 filter | +| 按日期降序取最新5条 | `--sort '[...]' --limit 5` | `--all` 拉全量再本地 sort + slice | +| 模糊搜索标题含"Q1" | `--filters` 用 `contain` 操作符 | 全量拉取再本地 grep | + +### 2.3 服务端无法完成时,才用 --all + 本地计算 + +以下场景服务端 filters/sort 无法满足,需要 `--all` 后本地处理: + +- SUM / AVG / COUNT / MAX / MIN 聚合 +- 分组统计(GROUP BY) +- 多字段联合计算(如"销售额 = 单价 × 数量") +- 去重计数(COUNT DISTINCT) +- 百分比/占比计算 + +### 2.4 --all 使用注意 + +```bash +dws aitable record query \ + --base-id \ + --table-id \ + --all \ + --field-ids <只取需要的字段> \ + --format json +``` + +- **必须配合 `--field-ids`** 限制返回字段,减少数据量 +- 对于大表(>1000条),先告知用户可能耗时 +- `--all` 会自动处理分页,无需手动翻页 + +## 3. filters 快速参考 + +详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。 + +### 常用操作符速查 + +| 操作符 | 适用类型 | 含义 | 示例 operands | +|--------|---------|------|-------------| +| `eq` | 通用 | 等于 | `["fldXXX", "值"]` | +| `ne` | 通用 | 不等于 | `["fldXXX", "值"]` | +| `gt` / `lt` | 数值/日期 | 大于/小于 | `["fldXXX", "25"]` | +| `gte` / `lte` | 数值/日期 | 大于等于/小于等于 | `["fldXXX", "100"]` | +| `contain` | 文本 | 包含 | `["fldXXX", "关键词"]` | +| `exist` / `un_exist` | 通用 | 有值/为空 | `["fldXXX"]`(无第二参数) | +| `any_of` | 多选 | 包含任一 | `["fldXXX", "选项A"]` | + +### filters 结构模板 + +```json +{ + "operator": "and", + "operands": [ + {"operator": "eq", "operands": ["", "<值>"]}, + {"operator": "gt", "operands": ["", "<数值>"]} + ] +} +``` + +## 4. 分析结果呈现规范 + +### 4.1 必须包含的信息 + +- **数据范围**:基于哪个表、哪些筛选条件、查询了多少条记录 +- **计算方法**:用了什么聚合方式(sum/count/avg 等) +- **结果值**:精确到合理小数位 + +### 4.2 示例 + +> 基于「销售数据」表,筛选条件:日期 ≥ 2026-01-01,共查询到 342 条记录。 +> - 总销售额:¥1,234,567.89(SUM) +> - 平均单价:¥3,610.46(AVG) +> - 最大单笔:¥89,000.00(MAX) + +## 5. 任务选路心智模型 + +| 用户诉求 | 优先方案 | 不要误走 | +|---------|---------|---------| +| 一次性统计/临时分析 | `record query --all` + 本地聚合 | 不要创建 formula 字段 | +| 长期展示派生指标 | 创建 formula 字段(见 [formula-guide](./aitable-formula-guide.md)) | 不要每次手算再手动写入 | +| 按条件筛选记录 | `record query --filters` | 不要 `--all` 拉全量再本地 filter | +| 取最新/最大/前N | `--sort + --limit` | 不要 `--all` 再本地排序取前N | +| 关键词检索 | `record query --filters` 用 `contain` | 不要把表格当搜索引擎全文检索 | +| 验证"是否全部满足" | 反向 filters(筛不满足的),看是否有结果 | 不要 `--all` 逐条遍历 | diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-error-recovery.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-error-recovery.md new file mode 100644 index 00000000..e62fb0a4 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-error-recovery.md @@ -0,0 +1,128 @@ +# AI 表格错误恢复指南 + +> 当 CLI 命令返回错误时,按本文档的映射表判断恢复动作。 + +## 1. 错误响应结构 + +```json +{ + "status": "error", + "summary": "Failed to create records", + "trace_id": "2104a64c17790723347215232e085e" +} +``` + +- `status: "error"` 表示操作失败 +- `summary` 包含错误摘要信息 +- `trace_id` 用于问题追踪 + +## 2. 常见错误与恢复动作 + +### 2.1 记录操作错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| `Failed to create records` | cellValue 格式错误或字段类型不匹配 | 先 `field get` 确认字段类型,再按 [cell-value](./aitable-cell-value.md) 规范重构值 | +| `record not found` | record-id 不存在或已删除 | 用 `record query` 重新查询确认目标记录 | +| rating 字段写入超出 max | 值超出字段配置范围 | 检查字段 config 的 min/max,确保值在范围内 | +| singleSelect 写入对象格式但 id 不存在 | option id 无效 | 改用 name 字符串写入(推荐),或先 `field get` 获取有效 option id | + +### 2.2 字段操作错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| `Failed to create field` | config 格式错误或必填项缺失 | 检查 [field-properties](./aitable-field-properties.md) 中该类型的必填 config | +| `field not found` | field-id 不存在 | 用 `table get` 获取最新字段列表 | +| formula 创建失败 | 公式语法错误或引用字段名不匹配 | 先 `field get` 确认字段精确名称,再检查公式语法(见 [formula-guide](./aitable-formula-guide.md)) | +| 删除主字段失败 | 主字段(第一列)不可删除 | 改为更新字段名或类型,不能删除 | + +### 2.3 Base/Table 操作错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| `base not found` | base-id 错误或无权限 | 确认 base-id 正确;尝试 `base list` 或 `base search` 重新定位 | +| `table not found` | table-id 错误 | 用 `table get --base-id ` 不带 table-ids 查看所有表 | +| 表名重复 | 同 Base 下已存在同名表 | 系统会自动续号(如"原名 1"),无需额外处理 | + +### 2.4 视图操作错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| `view not found` | view-id 错误 | 用 `view get --base-id --table-id ` 查看所有视图 | +| 删除最后一个视图 | 表至少保留一个视图 | 不可删除唯一视图 | + +### 2.5 filters/sort 错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| filters 无效被忽略 | 根节点不是 and/or,或 operands 格式错误 | 确保 filters 根节点是 `{"operator":"and"/"or", "operands":[...]}` 结构 | +| sort 无效 | fieldId 不存在 | 先 `table get` 确认字段 ID | +| 筛选结果为空 | 条件过严或字段值不匹配 | 放宽条件验证;注意 singleSelect 筛选值用 option name 或 id | + +### 2.6 导入导出错误 + +| 错误现象 / summary | 原因 | 恢复动作 | +|-------------------|------|---------| +| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id ` 轮询直到完成 | +| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 | + +## 3. 重试策略 + +### 3.1 可重试的错误 + +| 错误类型 | 重试方式 | 最大重试次数 | +|---------|---------|------------| +| 网络超时 / 5xx | 等待 2s 后原样重试 | 2 | +| 导出任务未完成 | 轮询 task-id | 5(间隔 3s) | +| 并发写入冲突 | 串行重试 | 1 | + +### 3.2 不可重试的错误(立即停止) + +| 错误类型 | 原因 | 处理方式 | +|---------|------|---------| +| 权限不足 / 403 | 用户对该 Base 无权限 | 停止操作,提示用户确认权限 | +| 参数格式错误 | 请求结构不合法 | 修正参数后重试,不要原样重试 | +| 资源不存在 / 404 | ID 错误或资源已删除 | 重新查询定位资源 | +| 配额超限 / 429 | API 调用频率过高 | 等待后重试,并降低并发 | + +### 3.3 重试前检查清单 + +在重试前,先确认: +1. ❓ 错误是暂时性的还是永久性的? +2. ❓ 参数有没有明显错误需要修正? +3. ❓ 是否需要先查询最新状态再重试? + +## 4. 调试技巧 + +### 4.1 使用 --verbose 获取详细信息 + +```bash +dws aitable record create \ + --base-id \ + --table-id \ + --records '[...]' \ + --verbose --format json +``` + +`--verbose` 会输出请求/响应的详细信息,帮助定位问题。 + +### 4.2 使用 --dry-run 预览 + +```bash +dws aitable record create \ + --base-id \ + --table-id \ + --records '[...]' \ + --dry-run --format json +``` + +`--dry-run` 只预览不执行,适合在不确定参数是否正确时先验证。 + +## 5. 错误预防最佳实践 + +1. **写记录前先读字段结构** — `field get` 或 `table get` 确认字段类型和 ID +2. **写字段前先读 field-properties** — 确认 config 的必填项和格式 +3. **formula 字段先确认引用字段名** — `[字段名]` 必须精确匹配 +4. **options 更新传完整列表** — 更新 singleSelect/multipleSelect 的 options 是全量覆盖 +5. **大批量操作分批执行** — 单次最多 100 条记录 +6. **使用 --format json** — 确保输出可解析,方便错误判断 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md new file mode 100644 index 00000000..f4a286ba --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md @@ -0,0 +1,57 @@ +# export & import — 导入导出 + +## 导出数据(两阶段轮询) + +`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。 + +> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。 + +```bash +# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json! +dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000 + +# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl +dws aitable export data --base-id --task-id --timeout-ms 3000 +``` + +### 参数约束 + +| scope | 必传参数 | +|-------|----------| +| `all` | 只需 `--base-id` | +| `table` | 必须 `--table-id` | +| `view` | 必须 `--table-id` + `--view-id` | + +## 导入文件(三步流程) + +当用户要求将 Excel(`.xlsx`)或 CSV 文件完整导入 AI 表格时,**不需要自己解析文件内容**,直接使用文件级导入。 + +> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。 + +```bash +# 第 1 步:申请上传凭证 +dws aitable import upload --base-id \ + --file-name data.xlsx --file-size <字节数> --format json +# → 返回 uploadUrl 和 importId + +# 第 2 步:上传文件到 OSS(注意:Content-Type 必须设为空) +curl -X PUT "" -H "Content-Type:" --data-binary @data.xlsx + +# 第 3 步:触发导入 +dws aitable import data --import-id --format json +# → 返回 status: success 和新建的 tableIds +``` + +### 步骤说明 + +| 步骤 | 命令 | 说明 | +|------|------|------| +| 申请上传凭证 | `import upload --base-id --file-name <名称> --file-size <字节>` | `--file-size` 必须与实际文件大小一致 | +| 上传文件 | HTTP PUT(curl 等) | **必须** 带 `-H "Content-Type:"` 将 Content-Type 设为空,否则 OSS 返回 403 | +| 触发导入 | `import data --import-id ` | 同步等待,大多一次调用即返回结果;超时可用相同 importId 重试 | + +### 适用/不适用场景 + +- **适用**:xlsx / csv 文件级别导入。导入后每个 Sheet 自动建表 +- **不支持**:追加到已有表、选择部分字段导入 +- **不适用**:需要写入已有表的特定列 → 应使用 `record create` diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-field-properties.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-field-properties.md new file mode 100644 index 00000000..710d685e --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-field-properties.md @@ -0,0 +1,238 @@ +# 字段类型 config 规范(field create / table create / field update) + +> 适用命令:`dws aitable field create`、`dws aitable table create --fields`、`dws aitable field update --config` +> +> 本文件是 DWS AI 表格字段 config 的 **source of truth**。创建/更新字段时,必须严格按此规范构造 JSON。 + +## 1. 顶层规则 + +- `table create --fields` 和 `field create --fields` 中每个字段对象:`{"fieldName":"xxx", "type":"xxx", "config":{...}}` +- `field create --name --type --config` 中 config 单独传 JSON 字符串 +- `field update --config` 只传 config 部分 +- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段 + +## 2. 字段类型速查 + +| type | 需要 config | config 核心字段 | 说明 | +|------|-------------|----------------|------| +| `text` | ❌ | — | 纯文本 | +| `number` | 可选 | `formatter` | 数字格式 | +| `singleSelect` | ✅ | `options` | 单选 | +| `multipleSelect` | ✅ | `options` | 多选 | +| `date` | 可选 | `formatter` | 日期格式 | +| `currency` | 可选 | `currencyType`, `formatter` | 货币 | +| `progress` | 可选 | `formatter`, `min`, `max`, `customizeRange` | 进度条 | +| `rating` | 可选 | `min`, `max`, `icon` | 评分 | +| `checkbox` | ❌ | — | 勾选框 | +| `user` | 可选 | `multiple` | 人员 | +| `department` | 可选 | `multiple` | 部门 | +| `group` | 可选 | `multiple` | 群组 | +| `url` | ❌ | — | 链接 | +| `richText` | ❌ | — | 富文本 | +| `telephone` | ❌ | — | 电话 | +| `email` | ❌ | — | 邮箱 | +| `attachment` | ❌ | — | 附件 | +| `geolocation` | ❌ | — | 地理位置 | +| `formula` | ✅ | `formula` | 公式(只读字段) | +| `unidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 单向关联 | +| `bidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 双向关联 | +| `creator` | ❌ | — | 系统字段:创建人(只读) | +| `lastModifier` | ❌ | — | 系统字段:最后编辑人(只读) | +| `createdTime` | ❌ | — | 系统字段:创建时间(只读) | +| `lastModifiedTime` | ❌ | — | 系统字段:最后编辑时间(只读) | + +## 3. 各类型 config 详解 + +### 3.1 number(数字) + +config 字段:`formatter` + +可选值: +- `INT` — 整数 +- `FLOAT_1` — 1 位小数 +- `FLOAT_2` — 2 位小数(默认) +- `FLOAT_3` — 3 位小数 +- `FLOAT_4` — 4 位小数 +- `THOUSAND` — 千分位整数 +- `THOUSAND_FLOAT` — 千分位 + 小数 +- `PERCENT` — 百分比(整数) +- `PERCENT_FLOAT` — 百分比(小数) + +```json +{"fieldName": "工时", "type": "number", "config": {"formatter": "FLOAT_2"}} +``` + +```json +{"fieldName": "完成率", "type": "number", "config": {"formatter": "PERCENT"}} +``` + +### 3.2 singleSelect / multipleSelect(单选 / 多选) + +config 字段:`options`(必填) + +options 结构: +- `options` 是数组,每项至少包含 `name` +- 创建时只传 `name`,`id` 由系统生成 +- **更新时**:已有选项必须回传原 `id`(从 `field get` 获取),新增选项不传 id + +```json +{ + "fieldName": "优先级", + "type": "singleSelect", + "config": { + "options": [ + {"name": "紧急"}, + {"name": "高"}, + {"name": "中"}, + {"name": "低"} + ] + } +} +``` + +更新已有字段时(保留原选项 + 新增): +```json +{ + "options": [ + {"id": "opt_existing_1", "name": "紧急"}, + {"id": "opt_existing_2", "name": "高"}, + {"id": "opt_existing_3", "name": "中"}, + {"name": "极低"} + ] +} +``` + +> 更新 options 是**全量覆盖**,不是追加!不传的旧选项会被删除,关联的单元格数据丢失。 + +### 3.3 date(日期) + +config 字段:`formatter` + +可选值: +- `YYYY-MM-DD`(默认) +- `YYYY-MM-DD HH:mm` +- `YYYY-MM-DD HH:mm:ss` +- `YYYY/MM/DD` +- `YYYY/MM/DD HH:mm` + +```json +{"fieldName": "截止日期", "type": "date", "config": {"formatter": "YYYY-MM-DD"}} +``` + +```json +{"fieldName": "创建时间", "type": "date", "config": {"formatter": "YYYY-MM-DD HH:mm"}} +``` + +### 3.4 currency(货币) + +config 字段:`currencyType`(必填)、`formatter`(可选) + +currencyType 可选值: +`CNY` | `HKD` | `USD` | `EUR` | `GBP` | `MOP` | `VND` | `JPY` | `KRW` | `AED` | `AUD` | `BRL` | `CAD` | `CHF` | `INR` | `IDR` | `MXN` | `MYR` | `PHP` | `PLN` | `RUB` | `SGD` | `THB` | `TRY` | `TWD` + +formatter 可选值(控制小数位):`INT` | `FLOAT_1` | `FLOAT_2`(默认)| `FLOAT_3` | `FLOAT_4` + +```json +{"fieldName": "预算", "type": "currency", "config": {"currencyType": "CNY", "formatter": "FLOAT_2"}} +``` + +### 3.5 progress(进度) + +config 字段:`formatter`(固定为 `PERCENT`)、`customizeRange`、`min`、`max` + +- 默认范围:0~1(即 0%~100%) +- 自定义范围时 `customizeRange` 必须为 `true` + +```json +{"fieldName": "完成度", "type": "progress", "config": {"formatter": "PERCENT"}} +``` + +自定义范围: +```json +{"fieldName": "进度", "type": "progress", "config": {"formatter": "PERCENT", "customizeRange": true, "min": 0, "max": 1}} +``` + +### 3.6 rating(评分) + +config 字段:`min`、`max`、`icon` + +- `min`:固定为 `1` +- `max`:1~10,默认 `5` +- `icon`:默认 `star` + +```json +{"fieldName": "满意度", "type": "rating", "config": {"min": 1, "max": 5, "icon": "star"}} +``` + +### 3.7 user / department / group(人员 / 部门 / 群组) + +config 字段:`multiple` + +- `multiple`:`true`(多选,默认)| `false`(单选) + +```json +{"fieldName": "负责人", "type": "user", "config": {"multiple": false}} +``` + +```json +{"fieldName": "协作部门", "type": "department", "config": {"multiple": true}} +``` + +### 3.8 formula(公式) + +config 字段:`formula`(必填) + +- 公式中引用字段使用**方括号 + 字段名**:`[字段名]` +- 支持的函数:参考钉钉 AI 表格公式文档 + +```json +{"fieldName": "合计", "type": "formula", "config": {"formula": "[单价] * [数量]"}} +``` + +```json +{"fieldName": "是否逾期", "type": "formula", "config": {"formula": "IF([截止日期] < NOW(), \"是\", \"否\")"}} +``` + +> ⚠️ formula 字段创建后为**只读**,不能通过 record create/update 写入值。 + +### 3.9 unidirectionalLink(单向关联) + +config 字段:`linkedTableId`(必填)、`multiple` + +- `linkedTableId`:目标表的 tableId +- `multiple`:`true`(多选,默认)| `false`(单选) + +```json +{"fieldName": "关联项目", "type": "unidirectionalLink", "config": {"linkedTableId": "tblXXXXXX", "multiple": true}} +``` + +### 3.10 bidirectionalLink(双向关联) + +config 字段:`linkedTableId`(必填)、`multiple` + +- 与单向关联参数相同 +- 创建后系统会**自动**在被关联表创建反向字段 + +```json +{"fieldName": "关联任务", "type": "bidirectionalLink", "config": {"linkedTableId": "tblYYYYYY", "multiple": true}} +``` + +## 4. AI 字段(ai-config) + +AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [aitable-field.md](./aitable-field.md) 中的 AI 字段创建示例。 + +核心规则: +- `outputType` 必须与 `--type` 对应:text→text, select→singleSelect, multiSelect→multipleSelect, number→number, currency→currency, image/video→attachment +- `prompt` 中必须至少包含一个 `fieldRef` 引用 +- 纯文本 prompt 会被后端拒绝 + +## 5. 常见错误 + +| 错误 | 说明 | +|------|------| +| options 更新时不传已有选项的 id | 会被视为新选项,旧选项被删除,关联数据丢失 | +| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 | +| formula 字段尝试写入值 | 只读字段,record create/update 会报错 | +| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 | +| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) | +| rating 值超出 max | 写入会报错 | diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-field.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-field.md new file mode 100644 index 00000000..79429a0d --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-field.md @@ -0,0 +1,100 @@ +# field — 字段管理 + +## field get — 获取字段详情 + +``` +Usage: + dws aitable field get [flags] +Example: + dws aitable field get --base-id --table-id + dws aitable field get --base-id --table-id --field-ids fld1,fld2 +Flags: + --base-id string Base ID (必填) + --field-ids string 字段 ID 列表,逗号分隔,单次最多 10 个 + --table-id string Table ID (必填) +``` + +返回字段的完整配置(含 options 等)。在 table get 拿到字段目录后,按需展开少量字段的完整配置。 + +## field create — 创建字段 + +``` +Usage: + dws aitable field create [flags] +Example: + dws aitable field create --base-id --table-id \ + --name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}' + + # 或者使用批量创建模式: + dws aitable field create --base-id --table-id \ + --fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]' +Flags: + --base-id string Base ID (必填) + --name string 要创建的单字段名称(与 --type 配合使用,替代 --fields) + --type string 要创建的单字段类型(参考 table create 字段类型) + --config string 单字段配置,如 options(可选) + --ai-config string 单字段 AI 配置 JSON(可选,用于创建 AI 字段) + --fields string 批量新增字段 JSON 数组,单次最多 15 个 (与 --name/--type 二选一) + --table-id string Table ID (必填) +``` + +允许部分成功,返回结果逐项标明成功/失败状态。 + +### AI 字段创建示例 + +```bash +dws aitable field create --base-id --table-id \ + --name "AI摘要" --type text \ + --ai-config '{ + "outputType":"text", + "prompt":[ + {"type":"text","value":"请将下面内容总结成不超过80字的中文摘要:"}, + {"type":"fieldRef","fieldId":"fld_content"} + ], + "autoRecompute":true, + "enableWebSearch":false, + "enableThinking":true + }' --format json +``` + +说明: +- `outputType` 与字段类型需一致(如 `outputType=text` 配 `--type text`) +- `prompt` 里通过 `fieldRef` 引用已有字段 +- `autoRecompute=true` 表示引用字段变化后自动重算 +- **AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝 + +## field update — 更新字段 + +``` +Usage: + dws aitable field update [flags] +Example: + dws aitable field update --base-id --table-id --field-id --name "新字段名" + dws aitable field update --base-id --table-id --field-id --config '{"options":[{"name":"A"},{"name":"B"}]}' +Flags: + --base-id string Base ID (必填) + --config string 字段配置 JSON (不修改时省略) + --ai-config string AI 配置 JSON (不修改时省略) + --field-id string Field ID (必填) + --name string 新字段名称 (不修改时省略) + --table-id string Table ID (必填) +``` + +- 不可变更字段类型 +- 更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id +- `--name` / `--config` / `--ai-config` 至少传一个 + +## field delete — 删除字段 + +``` +Usage: + dws aitable field delete [flags] +Example: + dws aitable field delete --base-id --table-id --field-id --yes +Flags: + --base-id string Base ID (必填) + --field-id string 待删除字段 ID (必填) + --table-id string Table ID (必填) +``` + +不可逆。禁止删除主字段和最后一个字段。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-filter-sort.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-filter-sort.md new file mode 100644 index 00000000..a56eed44 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-filter-sort.md @@ -0,0 +1,85 @@ +# filters & sort — 筛选排序语法参考 + +## filters 结构规范 + +### 强制规则 + +1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符 +2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中 +3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值 +4. fieldId 必须通过 `table get` 或 `field get` 获取,不能直接用字段名称 + +### 精简防呆模板 + +```json +{ + "operator": "and", + "operands": [ + { + "operator": "eq", + "operands": ["fld_state", "进行中"] + } + ] +} +``` + +4 种衍生: +- **OR 查询**:根节点 `"operator"` 改为 `"or"` +- **多条件 AND**:在 `"operands"` 数组中增加对象 +- **文本包含**:内层 `"operator"` 改为 `"contain"` +- **为空判断**:`"operator":"un_exist"`,operands 只需 `["fieldId"]` + +### 支持的操作符(已验证完整列表) + +| 操作符 | 含义 | operands 格式 | +|--------|------|--------------| +| `eq` / `ne` | 等于 / 不等于 | `["fieldId", "value"]` | +| `contain` / `exclusive` | 包含 / 不包含(文本模糊) | `["fieldId", "value"]` | +| `gt` / `gte` / `lt` / `lte` | 大于 / ≥ / 小于 / ≤ | `["fieldId", "numStr"]` | +| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) | +| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` | +| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` | +| `not_before` / `not_after` | 不早于 / 不晚于 | `["fieldId", "dateStr"]` | +| `from_now` | 从现在起 N 天内 | `["fieldId", "天数"]` | +| `date_between` | 日期区间 | `["fieldId", "[startTs, endTs]"]` | + +> ⚠️ 注意操作符拼写:不等于是 `ne`(不是 `not_eq`),不包含是 `exclusive`(不是 `not_contain`),为空是 `un_exist`(不是 `is_empty`)。 + +### 错误示例 + +❌ **缺失根节点 and/or**(API 将忽略该 filter,返回全表): +```json +{"operator":"eq","operands":["fldXXX","本科"]} +``` + +❌ **传入选项 ID 而非名称**(可能导致匹配不到 0 记录): +```json +{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]} +``` + +### 完整示例 + +单条件: +```bash +dws aitable record query --base-id X --table-id Y \ + --filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]}]}' +``` + +多条件 AND: +```bash +dws aitable record query --base-id X --table-id Y \ + --filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]},{"operator":"gt","operands":["fldStockId","0"]}]}' +``` + +## sort 结构规范 + +`--sort` 传 JSON 数组,排序方向字段**必须是 `direction`**,不要使用 `order`。 + +```bash +--sort '[{"fieldId":"fldXXX","direction":"desc"}]' +``` + +多字段排序: +```bash +--sort '[{"fieldId":"fldPriority","direction":"desc"},{"fieldId":"fldCreatedAt","direction":"asc"}]' +``` diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-formula-guide.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-formula-guide.md new file mode 100644 index 00000000..3b3fd853 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-formula-guide.md @@ -0,0 +1,224 @@ +# AI 表格公式字段指南 + +> 当用户要创建 formula 类型字段、编写表内计算公式、做派生指标时,必须先读本文档。 + +## 1. 何时使用 formula 字段 + +| 场景 | 用 formula | 不用 formula | +|------|-----------|-------------| +| 长期展示在表中的派生值(如"总价=单价×数量") | ✅ | | +| 条件标记(如"超期=IF(截止日期 \ + --table-id \ + --name "总价" \ + --type formula \ + --config '{"formula": "[单价] * [数量]"}' \ + --format json +``` + +### config 结构 + +```json +{ + "formula": "<公式表达式>" +} +``` + +- `formula` 是唯一必填字段 +- 表达式中引用字段使用 **方括号 + 字段名**:`[字段名]` +- 字段名必须精确匹配(含空格、大小写) + +## 3. 公式语法 + +### 3.1 引用规则 + +| 引用方式 | 语法 | 说明 | +|---------|------|------| +| 引用本表字段 | `[字段名]` | 字段名必须精确匹配 | +| 引用关联表字段 | 不支持 | 需要用 lookup 字段 | + +### 3.2 常用函数分类 + +#### 数值计算 + +| 函数 | 用途 | 示例 | +|------|------|------| +| `+` `-` `*` `/` | 四则运算 | `[单价] * [数量]` | +| `SUM(...)` | 求和 | `SUM([Q1], [Q2], [Q3], [Q4])` | +| `ROUND(value, digits)` | 四舍五入 | `ROUND([金额] * 0.1, 2)` | +| `ABS(value)` | 绝对值 | `ABS([差额])` | +| `MAX(a, b, ...)` | 最大值 | `MAX([成绩1], [成绩2])` | +| `MIN(a, b, ...)` | 最小值 | `MIN([报价1], [报价2])` | + +#### 文本处理 + +| 函数 | 用途 | 示例 | +|------|------|------| +| `&` | 文本拼接 | `[姓] & [名]` | +| `CONCATENATE(...)` | 拼接多个值 | `CONCATENATE([城市], "-", [区])` | +| `LEFT(text, n)` | 取左侧 n 字符 | `LEFT([编号], 4)` | +| `RIGHT(text, n)` | 取右侧 n 字符 | `RIGHT([手机], 4)` | +| `LEN(text)` | 文本长度 | `LEN([备注])` | +| `UPPER(text)` / `LOWER(text)` | 大小写转换 | `UPPER([代码])` | + +#### 逻辑判断 + +| 函数 | 用途 | 示例 | +|------|------|------| +| `IF(条件, 真值, 假值)` | 条件判断 | `IF([金额] > 1000, "大额", "普通")` | +| `AND(a, b, ...)` | 逻辑与 | `IF(AND([状态]="完成", [评分]>=4), "优秀", "")` | +| `OR(a, b, ...)` | 逻辑或 | `IF(OR([等级]="A", [等级]="B"), "通过", "未通过")` | +| `NOT(expr)` | 逻辑非 | `NOT([已归档])` | +| `SWITCH(expr, v1, r1, v2, r2, ..., default)` | 多条件匹配 | `SWITCH([状态], "待办","🔴", "进行中","🟡", "完成","🟢", "")` | + +#### 日期函数 + +| 函数 | 用途 | 示例 | +|------|------|------| +| `TODAY()` | 当前日期 | `IF([截止日期] < TODAY(), "已逾期", "正常")` | +| `NOW()` | 当前时间 | `NOW()` | +| `YEAR(date)` / `MONTH(date)` / `DAY(date)` | 提取年/月/日 | `YEAR([创建时间])` | +| `DATEDIF(start, end, unit)` | 日期差 | `DATEDIF([开始], [结束], "d")` 返回天数 | +| `DATEADD(date, count, unit)` | 日期加减 | `DATEADD([创建时间], 7, "d")` | + +> `DATEDIF` 的 unit 参数:`"y"`=年, `"m"`=月, `"d"`=天 + +#### 空值处理 + +| 函数 | 用途 | 示例 | +|------|------|------| +| `BLANK()` | 空值常量 | `IF([备注] = BLANK(), "无", [备注])` | +| `IF(field, ...)` | 字段为空时视为 false | `IF([评分], [评分], 0)` | + +## 4. 常见公式模板 + +### 4.1 计算类 + +``` +// 含税价格 +[不含税价] * (1 + [税率]) + +// 完成率百分比 +[已完成数] / [总数] + +// 折扣后价格 +[原价] * (1 - [折扣率]) +``` + +### 4.2 状态标记类 + +``` +// 逾期标记 +IF([截止日期] < TODAY(), "⚠️ 已逾期", "正常") + +// 优先级标签 +SWITCH([优先级], "紧急","🔴P0", "高","🟠P1", "中","🟡P2", "低","🟢P3", "") + +// 进度状态 +IF([进度] >= 1, "✅ 已完成", IF([进度] > 0, "🔄 进行中", "⏳ 未开始")) +``` + +### 4.3 文本拼接类 + +``` +// 编号生成 +"PRJ-" & [项目编码] & "-" & [序号] + +// 地址拼接 +[省] & [市] & [区] & [详细地址] +``` + +## 5. 注意事项与限制 + +### 5.1 formula 字段是只读的 + +- formula 字段的值由系统自动计算,**不能通过 `record create/update` 写入** +- 如果用户要"设置某个计算结果",应引导其修改源字段 + +### 5.2 字段名必须精确 + +- 公式中的 `[字段名]` 必须与表中实际字段名完全一致 +- 创建 formula 字段前,先通过 `field get` 确认字段名 + +### 5.3 循环引用 + +- formula 字段不能引用自身 +- 不能形成 A→B→A 的循环引用 + +### 5.4 与跨表引用字段的区别 + +钉钉 AI 表格有两种跨表取值方式:`lookup`(关联引用)和 `filterUp`(查找引用)。 + +| 维度 | formula | lookup (关联引用) | filterUp (查找引用) | +|------|---------|-----------------|-------------------| +| 字段类型 | `formula` | `lookup` | `filterUp` | +| 数据来源 | 本表字段 | 通过已有关联字段(bidirectionalLink/unidirectionalLink)取关联表字段 | 直接指定目标表 + 筛选条件取值 | +| 前置条件 | 无 | 必须先有关联字段 | 无需关联字段 | +| 适用场景 | 本表内计算、条件判断 | "我关联了某条记录,取它的某个字段值" | "在另一张表里按条件查找记录并聚合取值" | + +#### lookup config(已验证) + +```json +{ + "associateField": "<本表中的关联字段 fieldId(bidirectionalLink/unidirectionalLink 类型)>", + "valuesField": "<关联目标表中要取值的字段 fieldId>", + "aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE" +} +``` + +创建示例: +```bash +dws aitable field create --base-id --table-id \ + --name "关联名称" --type lookup \ + --config '{"associateField":"","valuesField":"","aggregator":"CONCATENATE"}' +``` + +#### filterUp config(已验证) + +```json +{ + "targetSheet": "<目标表 tableId>", + "filters": [ + { + "fieldId": "<目标表字段Id>", + "operator": "equal|contain", + "value": "<匹配值>", + "link": "AND" + } + ], + "valuesField": "<目标表中要取值的字段Id>", + "aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE" +} +``` + +> `filters` 必须非空(至少一条筛选规则)。 +> `filters[].operator` 仅支持:`equal`、`contain`(`not_equal`/`not_contain`/`is_empty` 等均不支持)。 +> `filters[].link` 统一为 `"AND"` 或 `"OR"`。 + +### 5.5 创建前检查清单 + +1. 已通过 `field get` 确认所有引用字段的精确名称 +2. 引用字段不包含 formula/lookup 等只读字段(可能导致二次计算延迟) +3. 公式语法正确(括号匹配、函数名正确) +4. 字段类型兼容(数值运算的字段确实是 number 类型) + +## 6. 更新 formula 字段 + +```bash +dws aitable field update \ + --base-id \ + --table-id \ + --field-id \ + --config '{"formula": "[新字段A] + [新字段B]"}' \ + --format json +``` + +更新时只需传新的 `formula` 表达式,系统会自动重新计算所有记录。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md new file mode 100644 index 00000000..eb737228 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md @@ -0,0 +1,45 @@ +# record create — 新增记录 + +## 命令格式 + +``` +Usage: + dws aitable record create [flags] +Example: + dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldTextId":"文本内容","fldNumId":123}}]' +Flags: + --base-id string Base ID (必填) + --records string 记录列表 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一) + --records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境) + --table-id string Table ID (必填) +``` + +## Windows / 超长 JSON 推荐 + +将 records JSON 写入文件,用 `--records-file ./records.json` 传入,避免命令行截断和引号转义问题。 + +## 常见错误(严格避免) + +| 错误 | 说明 | +|------|------| +| 参数名用 `--data` | ❌ 参数名是 `--records`,不是 `--data` | +| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) | +| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 | +| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 | + +## 正确流程 + +```bash +# 先获取 fieldId +dws aitable table get --base-id --table-id --format json +# 从返回中提取 fieldId(如 fldABC123) + +# 再用 fieldId 写入记录 +dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldABC123":"Python入门"}}]' --format json +``` + +## cells 写入格式 + +各字段类型的写入格式见 [aitable-cell-value.md](./aitable-cell-value.md)。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-delete.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-delete.md new file mode 100644 index 00000000..34a0e7a2 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-delete.md @@ -0,0 +1,20 @@ +# record delete — 删除记录 + +## 命令格式 + +``` +Usage: + dws aitable record delete [flags] +Example: + dws aitable record delete --base-id --table-id --record-ids rec1,rec2 --yes +Flags: + --base-id string Base ID (必填) + --record-ids string 待删除记录 ID 列表,逗号分隔,最多 100 条 (必填) + --table-id string Table ID (必填) +``` + +## 注意事项 + +- **不可逆操作**,调用前建议先 `record query` 确认目标记录 +- 需要先通过 `record query` 获取 recordId +- 单次最多删除 100 条记录 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-query.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-query.md new file mode 100644 index 00000000..8d04ab1b --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-query.md @@ -0,0 +1,74 @@ +# record query — 查询记录 + +## 命令格式 + +``` +Usage: + dws aitable record query [flags] +Example: + dws aitable record query --base-id --table-id + dws aitable record query --base-id --table-id --record-ids rec1,rec2 + dws aitable record query --base-id --table-id --query "关键词" --limit 50 +Flags: + --base-id string Base ID (必填) + --cursor string 分页游标,首次不传 + --field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个 + --filters string 结构化过滤条件 JSON + --query string 全文关键词搜索 + --limit int 单次最大记录数,默认 100,最大 100 + --record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个 + --sort string 排序条件 JSON 数组 + --table-id string Table ID (必填) + --all 启用自动翻页,循环获取并合并所有记录后统一输出 + --page-limit int 自动翻页最大页数(仅 --all 时生效)。默认 50,设为 0 表示无限制 +``` + +两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。 + +## 自动翻页(--all + --page-limit) + +- 传入 `--all` 启用自动翻页,CLI 自动循环获取并合并所有记录后统一输出 +- `--page-limit` 控制最大翻页次数,默认 50 页(5000 条),设为 0 表示无限制 +- 页间间隔 200ms,中途网络错误会 graceful stop 并输出已获取的数据 +- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取 +- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理) + +```bash +# 默认(最多 50 页 = 5000 条) +dws aitable record query --base-id X --table-id Y --all +# 无限制(拉完为止) +dws aitable record query --base-id X --table-id Y --all --page-limit 0 +# 从上次断点继续 +dws aitable record query --base-id X --table-id Y --all --cursor "上次返回的cursor" +``` + +## 排序参数规范 + +`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),不要使用 `order`。 + +正确示例: +```bash +--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]' +``` + +## filters 结构 + +详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。 + +快速模板: +```json +{"operator":"and","operands":[{"operator":"eq","operands":["",""]}]} +``` + +> **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。 + +## 减少响应体积 + +字段较多时,用 `--field-ids` 仅返回需要的字段,可显著减少返回数据量。 + +## 常见错误 + +- `--filters` 根节点直接用 `"operator":"eq"` → API 静默忽略,返回全表 +- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"` +- 不加 `--field-ids` 拉全字段 → 大表响应体积过大 +- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-update.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-update.md new file mode 100644 index 00000000..2e67abf4 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-update.md @@ -0,0 +1,42 @@ +# record update — 更新记录 + +## 命令格式 + +``` +Usage: + dws aitable record update [flags] +Example: + dws aitable record update --base-id --table-id \ + --records '[{"recordId":"recXXX","cells":{"fldStatusId":"已完成"}}]' +Flags: + --base-id string Base ID (必填) + --records string 待更新记录 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一) + --records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境) + --table-id string Table ID (必填) +``` + +只需传入需修改的字段,未传入的保持原值。每条记录必须含 recordId 和 cells。 + +## 高频错误 flag(LLM 极易踩坑,必读) + +CLI **没有** `--record-id` 和 `--cells` 两个独立 flag,**只接受 `--records` 一个参数**,格式为 JSON 数组。 +即使只改一条记录,也必须包在数组里。 + +| 错误(LLM 直觉) | 正确 | +|---|---| +| `--record-id recXXX --cells '{"fldX":"值"}'` | `--records '[{"recordId":"recXXX","cells":{"fldX":"值"}}]'` | +| `--id recXXX --data '{"fldX":"值"}'` | 同上 | +| `--record-id recXXX --field fldX --value "新值"` | 同上 | + +## 单条更新模板(直接复制) + +```bash +dws aitable record update --base-id --table-id \ + --records '[{"recordId":"","cells":{"":"新值"}}]' --format json +``` + +## 引号转义提示 + +- Linux/macOS:外层用单引号 `'[...]'`,内部 JSON 用双引号即可 +- Windows PowerShell:外层用双引号 `"[...]"`,内部双引号需转义为 `\"` +- 或将 JSON 写入临时文件,用 `--records-file ./records.json` 规避转义 diff --git a/skills/multi/dingtalk-aitable/references/field-rules.md b/skills/multi/dingtalk-aitable/references/field-rules.md new file mode 100644 index 00000000..7268291f --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/field-rules.md @@ -0,0 +1,128 @@ +# 易混淆操作与字段规则 + +## 易混淆操作 (高风险场景必读) + +| 用户说的 | 正确命令 | 不是这个 | +|---------|----------|---------| +| "创建一个新表格 (Base)" | `base create` | 不是 `table create` | +| "在表格里加一个数据表" | `table create` | 不是 `base create` | +| "看看表格里有哪些表" | `base get` | 不是 `field get` | +| "看看表里有哪些列" | `field get` / `table get` | 不是 `base get` | +| "搜索表格" (找 Base) | `base search` | 不是 `record query` | +| "搜索记录" (查表内数据) | `record query` | 不是 `base search` | +| "删掉这个数据表" | `table delete` | 不是 `record delete` | +| "删掉这条数据" | `record delete` | 不是 `table delete` | +| "删掉这个列" | `field delete` | 不是 `record delete` | +| "改字段类型" | 先 `field delete` 再 `field create` | `field update` **不能改类型** | +| "移动字段/调整字段顺序" | `view update --config '{"visibleFieldIds":[...]}'`(视图层重排,首列主字段必须保留在第一位) | 没有 `field reorder`/`field move` 命令;不能改字段在元数据里的"原始定义顺序" | + +## field 子命令总览 + +> ⚠️ field 有且仅有以下 **4 个** 子命令,没有 `list`、`reorder`、`move`: + +| 子命令 | 用途 | +|-------|------| +| `field get` | 获取字段详情(含完整 config/options)。**不是 `field list`** | +| `field create` | **创建字段(支持通过 config.options 设置选项)** | +| `field update` | 更新字段名称或配置(**不能改类型**,**不能改顺序**) | +| `field delete` | 删除字段(不可逆) | + +> **想"调整字段顺序"?请使用 `view update`**(视图层操作,不属于 `field` 子命令): +> - 通过 `--config '{"visibleFieldIds":["fld1","fld2",...]}'` 传入 fieldId 数组,数组顺序即视图中的字段显示顺序 +> - 仅影响**该视图**的列排列,同一 table 的其他视图与字段元数据原始顺序不变 +> - **首列字段(主字段)必须保留在数组第一位**,不能移动到非首位 +> - **漏传的字段不会被隐藏**,而是会被 API 自动追加到列表末尾 +> - **读写命名不一致**:写入键名 `visibleFieldIds`,但读出(`view get`)时该字段在 view 里叫 `columns`——校验顺序时请看 `views[].columns` + +## field create 高频错误 flag(LLM 极易踩坑) + +> 以下是 LLM 生成命令时**最常犯的 flag 错误**,每个都会返回 `unknown flag` 或被静默忽略。 +> **CLI 只接受 `--name` / `--type` / `--config`(单字段模式)或 `--fields`(批量模式),没有其他 flag 表达字段名 / 类型 / 选项。** + +| 错误写法(LLM 直觉) | 正确写法 | 说明 | +|---|---|---| +| `--field-name "状态"` | `--name "状态"` | 没有 `--field-name`,flag 就是 `--name` | +| `--field-type singleSelect` | `--type singleSelect` | 没有 `--field-type` | +| `--options "进行中,已完成,已归档"` | `--config '{"options":[{"name":"进行中"},{"name":"已完成"},{"name":"已归档"}]}'` | **没有 `--options` flag**,选项必须嵌套在 `--config` 的 JSON 里 | +| `--property '{"options":...}'` | `--config '{"options":...}'` | 没有 `--property`,对应 flag 是 `--config` | +| `--select-options "高,中,低"` | `--config '{"options":[{"name":"高"},{"name":"中"},{"name":"低"}]}'` | 同上,没有任何逗号分隔的简写语法 | +| `--field-config '{...}'` | `--config '{...}'` | 没有 `--field-config` | + +> **记忆口诀**:`field create` 的参数只有 4 个单词—— **name / type / config / fields**,其他任何变体拼写都是错的。 + +## 字段创建时设置 config(重要) + +创建 singleSelect/multipleSelect 字段时,**必须设置选项 (options)**: + +```bash +# 创建带选项的单选字段 (推荐新语法: --name/--type/--config) +dws aitable field create --base-id --table-id \ + --name "优先级" --type "singleSelect" \ + --config '{"options":[{"name":"高"},{"name":"中"},{"name":"低"}]}' \ + --format json + +# 建表时也可以直接通过 --fields 批量带选项字段 +dws aitable table create --base-id --name "任务表" \ + --fields '[{"fieldName":"任务","type":"text"},{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]' \ + --format json +``` + +> ⚠️ **不要混淆**: +> - **字段创建**(`field create` 的 `--config` 或 `table create` 的 `--fields`):创建时就指定 `options` +> - **记录写入**(`record create` / `record update` 的 `--records`):只能写入已存在的选项名称 + +## 主字段约束(table create 必读) + +> ⚠️ `table create` 的 `--fields` 中,**第一个字段自动成为主字段**。 +> 主字段只能是 **text** 类型,不能是 attachment、checkbox、formula 等。 + +**实际影响**:当用户要求创建的字段不适合做主字段时(如附件、复选框),必须: +1. 先放一个 text 字段作为第一个字段(主字段) +2. 再放用户要求的字段 +3. **告知用户**为何多了一个字段 + +```bash +# 例: 用户要求只创建附件字段 → 附件不能做主字段,必须先加 text 主字段 +dws aitable table create --base-id --name "产品图片" \ + --fields '[{"fieldName":"名称","type":"text"},{"fieldName":"产品图片","type":"attachment"}]' \ + --format json +``` + +## 只读字段 (不可写入) + +以下类型的字段不可写入, 执行 `field get` / `table get` 后识别并跳过: +- 创建时间 / 修改时间 (系统自动) +- 创建人 / 修改人 (系统自动) +- 自动编号 +- 公式字段 +- 引用字段 + +## 记录写入格式(record create / record update) + +> 以下是 **记录写入** 时 cells 的格式,不是字段创建的格式。 + +| 类型 | 写入格式 | 读取返回格式 | +|------|----------|-------------| +| text | `"fldXXX":"文本值"` | `"fldXXX":"文本值"` | +| number | `"fldXXX":123` | `"fldXXX":123` | +| singleSelect | `"fldXXX":"选项名"` (必须是已存在的选项) | `"fldXXX":{"id":"x","name":"选项名"}` | +| multipleSelect | `"fldXXX":["选项1","选项2"]` (必须是已存在的选项) | `"fldXXX":[{"id":"x","name":"选项1"}]` | +| date | `"fldXXX":"2026-03-04"` 或 Unix ms | `"fldXXX":1709510400000` (ms) | +| user | `"fldXXX":[{"userId":"123"}]` | `"fldXXX":{"uid":"123"}` | +| attachment | `[{"fileToken":""}]` — 需先通过 `attachment upload` 获取,见下方 ⬇️ | `[{"url":"...","filename":"...","size":N}]` | + +## ⚠️ 附件上传完整流程(必读!) + +> **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。 + +附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成: + +```bash +# 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS) +python3 scripts/upload_attachment.py /path/to/photo.png +# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 } + +# 步骤 2: 在 record create/update 中使用 fileToken +dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json +``` diff --git a/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py b/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py new file mode 100644 index 00000000..caa66f80 --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +通过 MCP 导出任务(export_data)导出 AI 表格,并可自动下载文件。 + +与普通命令的区别: +- 自动处理 taskId 轮询(直到拿到 downloadUrl 或达到轮询上限)。 +- 自动保存导出文件到本地(可选 --output)。 + +用法: + python scripts/aitable_export_via_task.py --scope all + python scripts/aitable_export_via_task.py --scope table --table-id + python scripts/aitable_export_via_task.py --scope view --table-id --view-id +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$") +ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"} + + +def validate_resource_id(resource_id: str) -> bool: + return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())) + + +def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]: + cmd = [dws_bin] + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", f"dws command timeout after {timeout_sec}s" + except FileNotFoundError: + return 127, "", f"dws binary not found: {dws_bin}" + + +def parse_json_output(raw: str) -> Optional[Dict[str, Any]]: + try: + obj = json.loads(raw) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None + + +def normalize_download_url(url: str) -> str: + if url.startswith("http://") or url.startswith("https://"): + return url + return f"https://{url}" + + +def download_file(url: str, output_path: Path) -> Tuple[bool, str]: + req = Request(url, method="GET") + try: + with urlopen(req, timeout=180) as resp: + if resp.status != 200: + return False, f"download http status: {resp.status}" + output_path.write_bytes(resp.read()) + return True, "" + except HTTPError as e: + body = e.read().decode("utf-8", "ignore") + return False, f"HTTP {e.code}: {body[:300]}" + except URLError as e: + return False, f"URL error: {e.reason}" + + +def fail(msg: str, code: int = 1) -> None: + print(f"错误:{msg}", file=sys.stderr) + sys.exit(code) + + +def build_start_args(args: argparse.Namespace) -> list[str]: + cmd = [ + "aitable", + "export", + "data", + "--base-id", + args.base_id, + "--scope", + args.scope, + "--format", + args.export_format, + "--timeout-ms", + str(args.timeout_ms), + ] + if args.table_id: + cmd.extend(["--table-id", args.table_id]) + if args.view_id: + cmd.extend(["--view-id", args.view_id]) + return cmd + + +def main() -> None: + parser = argparse.ArgumentParser(description="通过 MCP 导出任务导出 AI 表格") + parser.add_argument("base_id", help="目标 AI 表格 baseId") + parser.add_argument("--scope", choices=["all", "table", "view"], required=True, help="导出范围") + parser.add_argument("--table-id", help="scope=table/view 时必填") + parser.add_argument("--view-id", help="scope=view 时必填") + parser.add_argument("--export-format", default="excel", choices=sorted(ALLOWED_FORMATS), help="导出格式") + parser.add_argument("--timeout-ms", type=int, default=1000, help="单次等待毫秒数,默认 1000") + parser.add_argument("--poll-timeout-ms", type=int, default=3000, help="轮询等待毫秒数,默认 3000") + parser.add_argument("--max-polls", type=int, default=10, help="最大轮询次数,默认 10") + parser.add_argument("--output", help="本地保存路径(不传则按 fileName 保存到当前目录)") + parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws") + parser.add_argument("--no-download", action="store_true", help="仅返回 downloadUrl,不下载文件") + args = parser.parse_args() + + if not validate_resource_id(args.base_id): + fail("无效的 baseId 格式") + if args.scope in ("table", "view") and not args.table_id: + fail("scope=table/view 时必须传 --table-id") + if args.scope == "view" and not args.view_id: + fail("scope=view 时必须传 --view-id") + + print("[1/2] start export task", file=sys.stderr) + rc, out, err = run_dws(args.dws, build_start_args(args), timeout_sec=120) + if rc != 0: + fail(f"export_data 启动失败: {err or out}", rc) + obj = parse_json_output(out) + if not obj: + fail(f"export_data 返回非 JSON: {out[:300]}") + + data = obj.get("data", {}) or {} + status = obj.get("status") + if status == "error": + fail(f"export_data 返回失败: {json.dumps(obj, ensure_ascii=False)}") + + download_url = data.get("downloadUrl") + task_id = data.get("taskId") + file_name = data.get("fileName") or "export_result.bin" + + polls = 0 + while not download_url and task_id and polls < args.max_polls: + polls += 1 + print(f"[2/2] polling task ({polls}/{args.max_polls})", file=sys.stderr) + rc2, out2, err2 = run_dws( + args.dws, + [ + "aitable", + "export", + "data", + "--base-id", + args.base_id, + "--task-id", + task_id, + "--timeout-ms", + str(args.poll_timeout_ms), + ], + timeout_sec=max(120, int(args.poll_timeout_ms / 1000) + 60), + ) + if rc2 != 0: + fail(f"export_data 轮询失败: {err2 or out2}", rc2) + obj2 = parse_json_output(out2) + if not obj2: + fail(f"export_data 轮询返回非 JSON: {out2[:300]}") + if obj2.get("status") == "error": + fail(f"export_data 轮询返回失败: {json.dumps(obj2, ensure_ascii=False)}") + d2 = obj2.get("data", {}) or {} + download_url = d2.get("downloadUrl") or download_url + file_name = d2.get("fileName") or file_name + task_id = d2.get("taskId") or task_id + if not download_url: + time.sleep(0.2) + + result: Dict[str, Any] = { + "baseId": args.base_id, + "scope": args.scope, + "exportFormat": args.export_format, + "taskId": task_id, + "fileName": file_name, + "downloadUrl": download_url, + "polledTimes": polls, + } + + if not download_url: + result["status"] = "pending" + result["summary"] = "导出任务仍在处理中,请继续用 taskId 轮询。" + print(json.dumps(result, ensure_ascii=False, indent=2)) + sys.exit(3) + + if args.no_download: + result["status"] = "success" + result["summary"] = "导出完成(未下载文件)。" + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + norm_url = normalize_download_url(download_url) + output_path = Path(args.output).expanduser().resolve() if args.output else Path.cwd() / file_name + ok, dl_err = download_file(norm_url, output_path) + if not ok: + fail(f"downloadUrl 下载失败: {dl_err}") + + result["status"] = "success" + result["summary"] = "导出完成并已下载。" + result["savedPath"] = str(output_path) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/multi/dingtalk-aitable/scripts/aitable_import_via_task.py b/skills/multi/dingtalk-aitable/scripts/aitable_import_via_task.py new file mode 100644 index 00000000..fea1ad5e --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/aitable_import_via_task.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +通过 MCP 文件导入任务(prepare_import_upload -> PUT -> import_data)导入 AI 表格。 + +与 import_records.py 的区别: +- 本脚本:走“文件导入任务”链路,通常会新建导入数据表。 +- import_records.py:走 create_records,写入已有 table。 + +用法: + python scripts/aitable_import_via_task.py + python scripts/aitable_import_via_task.py --timeout 30 + python scripts/aitable_import_via_task.py --dws /tmp/dws +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$") +ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"} + + +def validate_resource_id(resource_id: str) -> bool: + return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())) + + +def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]: + cmd = [dws_bin] + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", f"dws command timeout after {timeout_sec}s" + except FileNotFoundError: + return 127, "", f"dws binary not found: {dws_bin}" + + +def parse_json_output(raw: str) -> Optional[Dict[str, Any]]: + try: + obj = json.loads(raw) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None + + +def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]: + payload = file_path.read_bytes() + req = Request(upload_url, data=payload, method="PUT") + # 关键:清空 Content-Type,避免 SignatureDoesNotMatch。 + req.add_header("Content-Type", "") + try: + with urlopen(req, timeout=180) as resp: + if resp.status == 200: + return True, "" + return False, f"unexpected HTTP status: {resp.status}" + except HTTPError as e: + body = e.read().decode("utf-8", "ignore") + return False, f"HTTP {e.code}: {body[:300]}" + except URLError as e: + return False, f"URL error: {e.reason}" + + +def fail(msg: str, exit_code: int = 1) -> None: + print(f"错误:{msg}", file=sys.stderr) + sys.exit(exit_code) + + +def main() -> None: + parser = argparse.ArgumentParser(description="通过文件导入任务导入 AI 表格") + parser.add_argument("base_id", help="目标 AI 表格 baseId") + parser.add_argument("file_path", help="待导入文件路径(.csv/.xlsx/.xls)") + parser.add_argument("--timeout", type=int, default=30, help="import_data 等待秒数,默认 30") + parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws") + args = parser.parse_args() + + base_id = args.base_id.strip() + file_path = Path(args.file_path).expanduser().resolve() + + if not validate_resource_id(base_id): + fail("无效的 baseId 格式") + if not file_path.exists() or not file_path.is_file(): + fail(f"文件不存在或不可读: {file_path}") + if file_path.suffix.lower() not in ALLOWED_EXTENSIONS: + fail(f"仅支持 {sorted(ALLOWED_EXTENSIONS)},当前文件: {file_path.name}") + + file_size = file_path.stat().st_size + if file_size <= 0: + fail("文件为空") + + print(f"[1/3] prepare import upload: {file_path.name} ({file_size} bytes)", file=sys.stderr) + rc, out, err = run_dws( + args.dws, + [ + "aitable", + "import", + "upload", + "--base-id", + base_id, + "--file-name", + file_path.name, + "--file-size", + str(file_size), + "--format", + "json", + ], + ) + if rc != 0: + fail(f"prepare_import_upload 失败: {err or out}", rc) + prepare_obj = parse_json_output(out) + if not prepare_obj: + fail(f"prepare_import_upload 返回非 JSON: {out[:300]}") + if prepare_obj.get("status") != "success": + fail(f"prepare_import_upload 返回失败: {json.dumps(prepare_obj, ensure_ascii=False)}") + + pdata = prepare_obj.get("data") or {} + upload_url = pdata.get("uploadUrl") + import_id = pdata.get("importId") + if not upload_url or not import_id: + fail(f"prepare_import_upload 缺少 uploadUrl/importId: {json.dumps(pdata, ensure_ascii=False)}") + + print("[2/3] upload file bytes via PUT", file=sys.stderr) + ok, put_err = put_file(upload_url, file_path) + if not ok: + fail(f"PUT 上传失败: {put_err}") + + print("[3/3] trigger import_data", file=sys.stderr) + rc2, out2, err2 = run_dws( + args.dws, + [ + "aitable", + "import", + "data", + "--import-id", + import_id, + "--timeout", + str(args.timeout), + "--format", + "json", + ], + timeout_sec=max(120, args.timeout + 30), + ) + if rc2 != 0: + fail(f"import_data 调用失败: {err2 or out2}", rc2) + import_obj = parse_json_output(out2) + if not import_obj: + fail(f"import_data 返回非 JSON: {out2[:300]}") + + result = { + "baseId": base_id, + "fileName": file_path.name, + "fileSize": file_size, + "importId": import_id, + "status": import_obj.get("status"), + "summary": import_obj.get("summary"), + "data": import_obj.get("data", {}), + "error": import_obj.get("error", {}), + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + + if import_obj.get("status") != "success": + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/skills/multi/dingtalk-aitable/scripts/bulk_add_fields.py b/skills/multi/dingtalk-aitable/scripts/bulk_add_fields.py new file mode 100644 index 00000000..98e0a9c7 --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/bulk_add_fields.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +批量添加字段到钉钉 AI 表格数据表(新版 schema) + +用法: + python bulk_add_fields.py fields.json + +fields.json 格式: + [ + {"fieldName": "字段 1", "type": "text"}, + {"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}}, + {"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": "高"}]}} + ] + +兼容写法: +- name 会自动映射为 fieldName +- phone 会自动映射为 telephone +""" + +import sys +import json +import subprocess +import os +import re +from pathlib import Path +from typing import Union, List, Dict, Any, Optional, Tuple + +JsonData = Union[List[Any], Dict[str, Any]] + +MAX_FILE_SIZE = 10 * 1024 * 1024 +ALLOWED_FILE_EXTENSIONS = ['.json'] +RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$') +ALLOWED_FIELD_TYPES = { + 'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency', + 'user', 'department', 'group', 'progress', 'rating', 'checkbox', + 'attachment', 'url', 'richText', 'telephone', 'email', 'idCard', + 'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink', + 'bidirectionalLink', 'creator', 'lastModifier', 'createdTime', + 'lastModifiedTime', +} +FIELD_TYPE_ALIASES = { + 'phone': 'telephone', +} + + +def resolve_safe_path(path: str, allowed_root: Optional[str] = None) -> Path: + if allowed_root is None: + allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd()) + + allowed_root = Path(allowed_root).resolve() + target_path = ( + Path(path).resolve() + if Path(path).is_absolute() + else (Path.cwd() / path).resolve() + ) + + try: + target_path.relative_to(allowed_root) + return target_path + except ValueError: + raise ValueError( + f"路径超出允许范围:{path}\n" + f"目标路径:{target_path}\n" + f"允许根目录:{allowed_root}\n" + f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内" + ) + + +def validate_resource_id(resource_id: str) -> bool: + return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())) + + +def validate_file_extension(filename: str, allowed_extensions: list) -> bool: + return any(filename.lower().endswith(ext) for ext in allowed_extensions) + + +def safe_json_load(file_path: Path, max_size: int = MAX_FILE_SIZE) -> JsonData: + file_size = file_path.stat().st_size + if file_size > max_size: + raise ValueError( + f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)" + ) + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(field) + if 'fieldName' not in normalized and 'name' in normalized: + normalized['fieldName'] = normalized.pop('name') + normalized['type'] = FIELD_TYPE_ALIASES.get( + normalized.get('type', 'text'), normalized.get('type', 'text') + ) + return normalized + + +def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]: + if not isinstance(field, dict): + return False, '字段配置必须是对象' + + field = normalize_field_config(field) + + if 'fieldName' not in field: + return False, '缺少必需字段:fieldName' + if not isinstance(field['fieldName'], str) or not field['fieldName'].strip(): + return False, 'fieldName 必须是非空字符串' + + field_type = field.get('type', 'text') + if field_type not in ALLOWED_FIELD_TYPES: + return False, f"不支持的字段类型:{field_type}" + + config = field.get('config') + if config is not None and not isinstance(config, dict): + return False, 'config 必须是对象' + + if field_type in {'singleSelect', 'multipleSelect'}: + options = (config or {}).get('options') + if not options or not isinstance(options, list): + return False, ( + 'singleSelect / multipleSelect 必须提供 config.options 数组' + ) + + if field_type in {'unidirectionalLink', 'bidirectionalLink'}: + linked_sheet_id = (config or {}).get('linkedSheetId') + if not linked_sheet_id or not validate_resource_id(linked_sheet_id): + return False, '关联字段必须提供合法的 config.linkedSheetId' + + return True, '' + + +def build_fields_json(fields: List[Dict[str, Any]]) -> str: + """构建 --fields 参数的 JSON 字符串。""" + payload_fields = [] + for field in fields: + normalized = normalize_field_config(field) + item: Dict[str, Any] = { + 'fieldName': normalized['fieldName'].strip(), + 'type': normalized.get('type', 'text'), + } + if 'config' in normalized and normalized['config'] is not None: + item['config'] = normalized['config'] + payload_fields.append(item) + return json.dumps(payload_fields, ensure_ascii=False) + + +def run_dws(args: List[str]) -> Optional[Dict[str, Any]]: + if not args: + print('错误:空命令') + return None + + cmd = ['dws'] + args + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}") + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"无法解析响应:{result.stdout[:200]}...") + print(f"JSON 解析错误:{e}") + return None + except subprocess.TimeoutExpired: + print('错误:命令执行超时(60 秒)') + return None + except FileNotFoundError: + print('错误:未找到 dws 命令,请确认已安装') + return None + + +def bulk_add_fields( + base_id: str, table_id: str, fields_file: str +) -> bool: + try: + safe_path = resolve_safe_path(fields_file) + except ValueError as e: + print(f"路径验证失败:{e}") + return False + + if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS): + print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件") + return False + if not safe_path.exists(): + print(f"错误:文件不存在:{safe_path}") + return False + + try: + fields = safe_json_load(safe_path) + except ValueError as e: + print(f"错误:{e}") + return False + except json.JSONDecodeError as e: + print(f"错误:JSON 格式无效:{e}") + return False + + if not isinstance(fields, list) or not fields: + print('错误:fields.json 必须是非空 JSON 数组') + return False + if len(fields) > 15: + print('错误:单次最多创建 15 个字段,请拆分后重试') + return False + + for i, field in enumerate(fields): + valid, error = validate_field_config(field) + if not valid: + print(f"错误:字段 #{i+1} 配置无效:{error}") + return False + + fields_json = build_fields_json(fields) + result = run_dws([ + 'aitable', 'field', 'create', + '--base-id', base_id, + '--table-id', table_id, + '--fields', fields_json, + '--format', 'json', + ]) + + if not result: + return False + + print(json.dumps(result, ensure_ascii=False, indent=2)) + return True + + +def main(): + if len(sys.argv) != 4: + print(__doc__) + print('用法示例:') + print(' python bulk_add_fields.py basexxx tablexxx fields.json') + sys.exit(1) + + base_id = sys.argv[1] + table_id = sys.argv[2] + fields_file = sys.argv[3] + + if not validate_resource_id(base_id): + print('错误:无效的 baseId 格式') + sys.exit(1) + if not validate_resource_id(table_id): + print('错误:无效的 tableId 格式') + sys.exit(1) + + success = bulk_add_fields(base_id, table_id, fields_file) + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-aitable/scripts/import_records.py b/skills/multi/dingtalk-aitable/scripts/import_records.py new file mode 100644 index 00000000..020b3cad --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/import_records.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema) + +用法: + python import_records.py data.csv [batch_size] + python import_records.py data.json [batch_size] + +说明: +- CSV 表头默认视为 fieldId +- JSON 支持两种格式: + 1. [{"cells": {"fldxxx": "value"}}, ...] + 2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells +""" + +import sys +import csv +import json +import subprocess +import os +import re +from pathlib import Path +from typing import Union, List, Dict, Any, Optional, Tuple + +JsonData = Union[List[Any], Dict[str, Any]] +RecordDict = Dict[str, str] + +MAX_FILE_SIZE = 50 * 1024 * 1024 +ALLOWED_CSV_EXTENSIONS = ['.csv'] +ALLOWED_JSON_EXTENSIONS = ['.json'] +RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$') +MAX_RECORDS_PER_BATCH = 100 +DEFAULT_BATCH_SIZE = 50 + + +def resolve_safe_path( + path: str, allowed_root: Optional[str] = None +) -> Path: + if allowed_root is None: + allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd()) + allowed_root = Path(allowed_root).resolve() + target_path = ( + Path(path).resolve() + if Path(path).is_absolute() + else (Path.cwd() / path).resolve() + ) + try: + target_path.relative_to(allowed_root) + return target_path + except ValueError: + raise ValueError( + f"路径超出允许范围:{path}\n" + f"目标路径:{target_path}\n" + f"允许根目录:{allowed_root}\n" + f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内" + ) + + +def validate_resource_id(resource_id: str) -> bool: + return bool( + resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()) + ) + + +def validate_file_extension( + filename: str, allowed_extensions: list +) -> bool: + return any(filename.lower().endswith(ext) for ext in allowed_extensions) + + +def safe_csv_load( + file_path: Path, max_size: int = MAX_FILE_SIZE +) -> List[RecordDict]: + file_size = file_path.stat().st_size + if file_size > max_size: + raise ValueError( + f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)" + ) + with open(file_path, 'r', encoding='utf-8', newline='') as f: + return list(csv.DictReader(f)) + + +def safe_json_load( + file_path: Path, max_size: int = MAX_FILE_SIZE +) -> JsonData: + file_size = file_path.stat().st_size + if file_size > max_size: + raise ValueError( + f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)" + ) + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def sanitize_record_value( + value: Any, +) -> Optional[Union[str, int, float, bool, list, dict]]: + if value is None: + return None + if isinstance(value, (bool, int, float, list, dict)): + return value + if not isinstance(value, str): + return value + if not value.strip(): + return None + + value = value.strip() + if value.lower() == 'true': + return True + if value.lower() == 'false': + return False + + try: + if '.' in value: + return float(value) + return int(value) + except ValueError: + return value + + +def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]: + if 'cells' in record and isinstance(record['cells'], dict): + cells = record['cells'] + else: + cells = record + normalized = {} + for key, value in cells.items(): + sanitized = sanitize_record_value(value) + if sanitized is not None: + normalized[key] = sanitized + return {'cells': normalized} + + +def validate_record( + record: Dict[str, Any], headers: List[str] +) -> Tuple[bool, str]: + if not isinstance(record, dict): + return False, '记录必须是对象' + normalized = normalize_record(record) + cells = normalized.get('cells', {}) + if not cells or not isinstance(cells, dict): + return False, '记录必须包含非空 cells 对象' + return True, '' + + +def run_dws(args: List[str]) -> Optional[Dict[str, Any]]: + if not args: + print('错误:空命令') + return None + cmd = ['dws'] + args + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}") + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"无法解析响应:{result.stdout[:200]}...") + print(f"JSON 解析错误:{e}") + return None + except subprocess.TimeoutExpired: + print('错误:命令执行超时(120 秒)') + return None + except FileNotFoundError: + print('错误:未找到 dws 命令,请确认已安装') + return None + + +def import_from_csv( + base_id: str, table_id: str, csv_file: str, + batch_size: int = DEFAULT_BATCH_SIZE, +) -> bool: + try: + safe_path = resolve_safe_path(csv_file) + except ValueError as e: + print(f"路径验证失败:{e}") + return False + + if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS): + print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件") + return False + if not safe_path.exists(): + print(f"错误:文件不存在:{safe_path}") + return False + + try: + rows = safe_csv_load(safe_path) + except ValueError as e: + print(f"错误:{e}") + return False + except csv.Error as e: + print(f"错误:CSV 格式无效:{e}") + return False + + if not rows: + print('错误:CSV 文件为空或没有有效数据行') + return False + + records = [ + normalize_record(row) + for row in rows + if normalize_record(row)['cells'] + ] + return import_records(base_id, table_id, records, batch_size) + + +def import_from_json( + base_id: str, table_id: str, json_file: str, + batch_size: int = DEFAULT_BATCH_SIZE, +) -> bool: + try: + safe_path = resolve_safe_path(json_file) + except ValueError as e: + print(f"路径验证失败:{e}") + return False + + if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS): + print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件") + return False + if not safe_path.exists(): + print(f"错误:文件不存在:{safe_path}") + return False + + try: + records = safe_json_load(safe_path) + except ValueError as e: + print(f"错误:{e}") + return False + except json.JSONDecodeError as e: + print(f"错误:JSON 格式无效:{e}") + return False + + if not isinstance(records, list) or not records: + print('错误:JSON 文件必须是非空数组') + return False + + for i, record in enumerate(records): + valid, error = validate_record(record, []) + if not valid: + print(f"错误:记录 #{i+1} 格式无效:{error}") + return False + + return import_records( + base_id, table_id, + [normalize_record(r) for r in records], batch_size, + ) + + +def import_records( + base_id: str, table_id: str, + records: List[Dict[str, Any]], batch_size: int, +) -> bool: + if batch_size <= 0: + print('错误:batch_size 必须大于 0') + return False + if batch_size > MAX_RECORDS_PER_BATCH: + batch_size = MAX_RECORDS_PER_BATCH + + total_batches = (len(records) + batch_size - 1) // batch_size + success = True + + for i in range(0, len(records), batch_size): + batch = records[i:i + batch_size] + batch_num = (i // batch_size) + 1 + records_json = json.dumps(batch, ensure_ascii=False) + result = run_dws([ + 'aitable', 'record', 'create', + '--base-id', base_id, + '--table-id', table_id, + '--records', records_json, + '--format', 'json', + ]) + if result: + print( + f"[{batch_num}/{total_batches}] " + f"✓ 已提交 {len(batch)} 条记录" + ) + else: + print(f"[{batch_num}/{total_batches}] ✗ 导入失败") + success = False + + return success + + +def main(): + if len(sys.argv) < 4 or len(sys.argv) > 5: + print(__doc__) + print('用法示例:') + print( + ' python import_records.py basexxx tablexxx data.csv 50' + ) + sys.exit(1) + + base_id = sys.argv[1] + table_id = sys.argv[2] + input_file = sys.argv[3] + batch_size = ( + int(sys.argv[4]) if len(sys.argv) == 5 + else DEFAULT_BATCH_SIZE + ) + + if not validate_resource_id(base_id): + print('错误:无效的 baseId 格式') + sys.exit(1) + if not validate_resource_id(table_id): + print('错误:无效的 tableId 格式') + sys.exit(1) + + if input_file.lower().endswith('.csv'): + success = import_from_csv( + base_id, table_id, input_file, batch_size + ) + elif input_file.lower().endswith('.json'): + success = import_from_json( + base_id, table_id, input_file, batch_size + ) + else: + print('错误:仅支持 .csv 或 .json 文件') + sys.exit(1) + + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-aitable/scripts/upload_attachment.py b/skills/multi/dingtalk-aitable/scripts/upload_attachment.py new file mode 100644 index 00000000..5b816a7e --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/upload_attachment.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +上传附件到钉钉 AI 表格 attachment 字段 + +完整流程(内部自动执行 3 步): + 1. dws aitable attachment upload → 获取 uploadUrl + fileToken + 2. HTTP PUT 上传文件到 OSS + 3. 返回 fileToken,可直接用于 record create/update + +用法: + python upload_attachment.py + +输出 (JSON): + { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 } + +然后在 record create/update 中使用: + dws aitable record create --base-id --table-id \ + --records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json +""" + +import sys +import json +import subprocess +import os +import mimetypes +import re +from pathlib import Path +from typing import Optional, Dict, Any +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$') +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB + + +def validate_resource_id(resource_id: str) -> bool: + return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())) + + +def detect_mime_type(file_path: Path) -> str: + """根据文件扩展名推断 MIME type。""" + mime_type, _ = mimetypes.guess_type(str(file_path)) + return mime_type or 'application/octet-stream' + + +def run_dws(args: list) -> Optional[Dict[str, Any]]: + """调用 dws 命令并返回解析后的 JSON 结果。""" + cmd = ['dws'] + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr) + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr) + return None + except subprocess.TimeoutExpired: + print('错误:dws 命令超时(60 秒)', file=sys.stderr) + return None + except FileNotFoundError: + print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr) + return None + + +def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool: + """通过 HTTP PUT 上传文件到 OSS。""" + file_data = file_path.read_bytes() + req = Request(upload_url, data=file_data, method='PUT') + req.add_header('Content-Type', mime_type) + + try: + with urlopen(req, timeout=120) as resp: + if resp.status == 200: + return True + print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr) + return False + except HTTPError as e: + print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr) + return False + except URLError as e: + print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr) + return False + + +def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]: + """ + 执行完整的附件上传流程: + 1. prepare_attachment_upload → uploadUrl + fileToken + 2. PUT 文件到 OSS + 3. 返回 fileToken 信息 + """ + # 验证文件 + file_path = Path(file_path_str).resolve() + if not file_path.exists(): + print(f"错误:文件不存在: {file_path}", file=sys.stderr) + return None + if not file_path.is_file(): + print(f"错误:不是文件: {file_path}", file=sys.stderr) + return None + + file_size = file_path.stat().st_size + if file_size <= 0: + print("错误:文件为空", file=sys.stderr) + return None + if file_size > MAX_FILE_SIZE: + print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr) + return None + + file_name = file_path.name + mime_type = detect_mime_type(file_path) + + # 步骤 1: prepare_attachment_upload + print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr) + dws_args = [ + 'aitable', 'attachment', 'upload', + '--base-id', base_id, + '--file-name', file_name, + '--size', str(file_size), + '--mime-type', mime_type, + '--format', 'json', + ] + result = run_dws(dws_args) + if not result: + return None + + status = result.get('status', '') + if status != 'success': + error = result.get('error', {}) + print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr) + return None + + data = result.get('data', {}) + upload_url = data.get('uploadUrl', '') + file_token = data.get('fileToken', '') + + if not upload_url or not file_token: + print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr) + return None + + # 步骤 2: PUT 文件到 OSS + print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr) + if not upload_to_oss(upload_url, file_path, mime_type): + return None + + # 步骤 3: 返回 fileToken + print(f"步骤 3/3: 上传完成!", file=sys.stderr) + output = { + "fileToken": file_token, + "fileName": file_name, + "size": file_size, + "mimeType": mime_type, + } + + return output + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + print('用法:') + print(' python upload_attachment.py ') + print() + print('示例:') + print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf') + print() + print('然后在 record create 中使用返回的 fileToken:') + print(' dws aitable record create --base-id --table-id \\') + print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json') + sys.exit(1) + + base_id = sys.argv[1] + file_path = sys.argv[2] + + if not validate_resource_id(base_id): + print('错误:无效的 baseId 格式', file=sys.stderr) + sys.exit(1) + + result = upload_attachment(base_id, file_path) + if result is None: + sys.exit(1) + + # 正常输出到 stdout(JSON 格式,方便解析) + print(json.dumps(result, ensure_ascii=False, indent=2)) + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-attendance/SKILL.md b/skills/multi/dingtalk-attendance/SKILL.md new file mode 100644 index 00000000..dc911d5f --- /dev/null +++ b/skills/multi/dingtalk-attendance/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dingtalk-attendance +description: 钉钉考勤(只读)。Use when 用户说 考勤/打卡记录/查打卡/查班次/考勤汇总/考勤规则/出勤情况。命令前缀:dws attendance。开源版仅支持只读查询,不支持创建班次、导入排班、修改考勤组等写操作。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉考勤 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[attendance.md](references/attendance.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查我 / 某人 某天的打卡" | `dws attendance record get --user --date ` | +| "查考勤组 / 考勤规则 / 打卡范围" | `dws attendance rules --date ` | +| "查班次 / 排班 / 谁今天上什么班" | `dws attendance shift list --users --start --end ` | +| "考勤统计 / 周月汇总 / 出勤天数" | `dws attendance summary --user --date "" --stats-type week\|month` | + +## 评测高频硬约束 + +- 开源版考勤只有 `record / rules / shift / summary` 四个只读命令;用户提到创建班次、导入排班、加人入考勤组等写操作时,直接告知"开源版不支持",不要伪装成功。 +- 查询迟到/缺勤名单时,空打卡结果不等于"没人迟到"。必须结合排班、`NotSigned`、`Absenteeism`、无记录人员分别说明;数据缺失要标为"无记录/无法判断",不要归为正常。 +- 做部门 Top N 排名时,用户要求前 N 名就必须输出 N 个部门;无打卡记录或无可计算数据的部门按 0 或"无数据"保留在排名中,不能只输出有数据的少数部门。 +- `summary` 必须同时传 `--user`、`--date`、`--stats-type`(week/month),缺一返回 C0002。 +- `shift list` 时间跨度不超过 7 天,最多 50 人;`record get` 一次只查一个用户一天。 +- 所有 dws 命令带 `--format json`,时间/日期按命令要求分别使用 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss`。 + +## 跨产品协作 + +- 拿到 userId 前先用 `dingtalk-aisearch` 解析人名 diff --git a/skills/multi/dingtalk-attendance/references/attendance.md b/skills/multi/dingtalk-attendance/references/attendance.md new file mode 100644 index 00000000..abc230ea --- /dev/null +++ b/skills/multi/dingtalk-attendance/references/attendance.md @@ -0,0 +1,112 @@ +# 考勤 (attendance) 命令参考 + +开源 `dws attendance` 仅支持以下 4 个只读子命令:`record / rules / shift / summary`。 + +> 写操作(创建班次、导入排班、修改考勤组成员/配置、保存个人规则、更新假期规则、设置假期余额等)在开源版均**不支持**。用户提到这类需求时,直接告知不支持,不要尝试拼命令。 + +## 命令总览 + +### 查询个人考勤详情 +``` +Usage: + dws attendance record get [flags] +Example: + dws attendance record get --user USER_ID --date 2026-03-08 +Flags: + --date string 查询日期, 格式 YYYY-MM-DD (必填) + --user string 钉钉用户 ID (必填) +``` + +一次只查一个用户一天的考勤详情。 + +### 查询考勤组与考勤规则 +``` +Usage: + dws attendance rules [flags] +Example: + dws attendance rules --date 2026-03-14 + dws attendance rules --date "2026-03-14 09:00:00" +Flags: + --date string 考勤日期, 格式 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss (必填) +``` + +查询当前用户所属的考勤组、打卡规则、弹性工时设置等。 + +### 批量查询员工班次 +``` +Usage: + dws attendance shift list [flags] +Example: + dws attendance shift list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07 +Flags: + --end string 结束日期, 格式 YYYY-MM-DD (必填) + --start string 开始日期, 格式 YYYY-MM-DD (必填) + --users string 用户 ID 列表, 逗号分隔, 最多 50 人 (必填) +``` + +批量查询多个员工在指定日期的班次信息。**时间跨度不超过 7 天,最多 50 人**。 + +### 查询某人的考勤统计摘要 +``` +Usage: + dws attendance summary [flags] +Example: + dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month + dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week +Flags: + --date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填) + --stats-type string 统计类型: week / month (必填, 钉钉服务端业务层强制要求, 不填返回 C0002) + --user string 钉钉用户 ID (必填) +``` + +`--user`、`--date`、`--stats-type` 均必填。 + +## 意图判断 + +用户说"打卡记录/某天考勤/某人今天打卡了吗" → `record get` +用户说"考勤组/考勤规则/打卡范围/弹性工时" → `rules` +用户说"班次/排班/今天上什么班/这周几点上下班" → `shift list` +用户说"考勤汇总/周统计/月统计/出勤天数/迟到次数" → `summary` + +## 核心工作流 + +```bash +# 查个人某天的考勤详情 +dws attendance record get --user --date 2026-03-08 --format json + +# 查考勤组和规则 +dws attendance rules --date 2026-03-14 --format json + +# 批量查班次(最多 50 人 / 7 天) +dws attendance shift list --users user001,user002 --start 2026-03-03 --end 2026-03-07 --format json + +# 查月度考勤汇总 +dws attendance summary --user --date "2026-03-12 15:00:00" --stats-type month --format json + +# 查周度考勤汇总 +dws attendance summary --user --date "2026-03-12 15:00:00" --stats-type week --format json +``` + +## 上下文传递表 +| 操作 | 提取 | 用于 | +|------|------|------| +| `contact user get-self` / `contact user search` | `userId` | record get / shift list / summary 的 `--user` / `--users` | +| `rules` | `groupId` | 进一步说明考勤组归属 | + +## 注意事项 + +- `record get` 的 `--date` 格式: `YYYY-MM-DD`,CLI 自动转换为毫秒时间戳 +- `rules` 的 `--date` 支持 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss` 两种格式 +- `shift list` 的 `--start/--end` 使用 `YYYY-MM-DD` 格式,跨度不超过 7 天;`--users` 最多 50 人 +- `summary` 的 `--date` 必须是 `yyyy-MM-dd HH:mm:ss` 格式;`--stats-type` 仅接受 `week` 或 `month` +- 所有命令必须显式带 `--format json` 便于解析 +- 用户 ID 需先从 `contact user get-self` 或 `aisearch person` 获取,不要凭记忆复用 + +## 严格约束 + +- 不要凭历史记忆复用 userId / groupId 等任何 ID,每次必须从当次命令返回值中提取 +- 不要猜测命令,先查 `--help` 明确命令 +- 查询迟到/缺勤名单时,空打卡结果不等于"没人迟到",必须结合 `NotSigned`、`Absenteeism`、无记录人员分别说明 +- 涉及超过 3 条记录的聚合(求和、分组、计数、排序)时必须落 Python 脚本处理,禁止口算或目测 +- 遇到时长字段时,注意区分单位是秒、分钟还是小时 +- 写操作(创建班次、导入排班、修改考勤组、设置假期余额等)开源版不支持,遇到这类需求直接告知,不要伪装成功 diff --git a/skills/multi/dingtalk-attendance/scripts/attendance_my_record.py b/skills/multi/dingtalk-attendance/scripts/attendance_my_record.py new file mode 100644 index 00000000..89898507 --- /dev/null +++ b/skills/multi/dingtalk-attendance/scripts/attendance_my_record.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +查看我今天/本周/指定日期的考勤记录(自动获取 userId) + +用法: + python attendance_my_record.py # 今天 + python attendance_my_record.py today # 今天 + python attendance_my_record.py 2026-03-10 # 指定日期 + python attendance_my_record.py --dry-run # 仅显示命令 +""" + +import sys +import json +import subprocess +import re +from datetime import datetime +from typing import List, Any, Optional + +DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$') + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def get_my_user_id(dry_run: bool = False) -> Optional[str]: + data = run_dws([ + 'contact', 'user', 'get-self', '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data or not isinstance(data, dict): + return None + return data.get('userId') or data.get('userid') + + +def main(): + dry_run = '--dry-run' in sys.argv + args = [a for a in sys.argv[1:] if a != '--dry-run'] + + date_str = args[0] if args else 'today' + if date_str == 'today': + date_str = datetime.now().strftime('%Y-%m-%d') + elif not DATE_PATTERN.match(date_str): + print(__doc__) + sys.exit(1) + + print('🔍 获取当前用户信息...') + user_id = get_my_user_id(dry_run=dry_run) + if not user_id and not dry_run: + print('错误:无法获取当前用户 ID') + sys.exit(1) + + print(f'📊 查询 {date_str} 考勤记录...\n') + data = run_dws([ + 'attendance', 'record', 'get', + '--user', user_id or '', + '--date', date_str, + '--format', 'json', + ], dry_run=dry_run) + + if dry_run: + return + if not data: + print('未查到考勤记录') + return + + print(f"📋 考勤记录 ({date_str})") + print('=' * 40) + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-attendance/scripts/attendance_team_shift.py b/skills/multi/dingtalk-attendance/scripts/attendance_team_shift.py new file mode 100644 index 00000000..bf91ad3d --- /dev/null +++ b/skills/multi/dingtalk-attendance/scripts/attendance_team_shift.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +查询团队成员本周排班和出勤统计 + +用法: + python attendance_team_shift.py --users userId1,userId2,userId3 + python attendance_team_shift.py --users userId1,userId2 \ + --from 2026-03-10 --to 2026-03-14 + python attendance_team_shift.py --users userId1 --dry-run +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def get_week_range(): + today = datetime.now() + monday = today - timedelta(days=today.weekday()) + friday = monday + timedelta(days=4) + return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d') + + +def main(): + parser = argparse.ArgumentParser( + description='查询团队成员排班和出勤统计' + ) + parser.add_argument( + '--users', required=True, help='用户 ID 列表,逗号分隔' + ) + mon, fri = get_week_range() + parser.add_argument('--from', dest='from_date', default=mon) + parser.add_argument('--to', dest='to_date', default=fri) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + user_count = len(args.users.split(',')) + if user_count > 50: + print('错误:最多查询 50 人') + sys.exit(1) + + print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})") + print(f" 人数: {user_count}") + print('=' * 50) + + print('\n🔍 查询排班信息...') + data = run_dws([ + 'attendance', 'shift', 'list', + '--users', args.users, + '--start', args.from_date, + '--end', args.to_date, + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + return + if not data: + print('未查到排班信息') + return + + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-calendar/SKILL.md b/skills/multi/dingtalk-calendar/SKILL.md new file mode 100644 index 00000000..ee6f727e --- /dev/null +++ b/skills/multi/dingtalk-calendar/SKILL.md @@ -0,0 +1,54 @@ +--- +name: dingtalk-calendar +description: 钉钉日历与会议室。Use when 用户说 约会议/查日程/订会议室/查闲忙/加参会人/改期/取消会议/今天的日程/本周日程/共同空闲。Distinct from dingtalk-conference(视频会议发起/预约/邀请入会/会中控制)、dingtalk-minutes(听记)、dingtalk-todo(待办)。命令前缀:dws calendar。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉日历 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[calendar.md](references/calendar.md);剧本:[03-meeting.md](references/03-meeting.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "今天 / 明天 / 本周日程" | `python scripts/calendar_today_agenda.py [today\|tomorrow\|week]` | +| "约会议(含参会人 + 会议室)" | `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起>" --end "<止>" [--users ] [--book-room]` | +| "多人共同空闲" | `python scripts/calendar_free_slot_finder.py --users --date ` | +| "查闲忙" | `dws calendar event list --start "" --end ""` | +| "加参会人" / "订房" / "取消" | `dws calendar participant add` / `room add` / `event delete` | + +## 执行硬约束 + +- 多轮日程任务必须保留 `eventId`,后续加人、移人、订房、换房、改描述、删除都基于同一个 `eventId` 执行;不要重新创建重复日程。 +- 用户明确说"帮我订一个空闲会议室"时,`room search` 返回可用会议室后直接选择第一个可预订且不需要自定义审批的 `roomId` 执行 `room add`;不要把选择权抛回用户导致任务停住。 +- 已有日程订房:`dws calendar room search --start ... --end ... --format json` → `dws calendar room add --event --rooms --format json` → `event get` 或 `room/busy` 验证。 +- 换会议室:先 `room delete --event --rooms `,再 `room add --event --rooms `,最后回查;不要只更新 `--location`。 +- 参会人变化用 `participant add/delete`,日程描述变化用 `event update --desc`,删除日程用 `event delete --id`。用户当前消息已明确要求删除/取消时可直接执行;否则先确认。 +- 脚本失败或参数不完整时,立即降级到明确的 `dws calendar event/participant/room` 命令,不要停在"我要查看用法"。 +- 所有 dws 命令带 `--format json`;查询时间必须显式 `--start` / `--end`。 + +## 跨产品协作 + +- 视频会议发起 / 入会链接 / 邀请入会 / 会中控制 → 切到 `dingtalk-conference` +- 会后摘要 / 待办 → 切到 `dingtalk-minutes` +- 参会人按人名 → 先用 `dingtalk-aisearch` 解析 + +## 注意 + +`schedule-meeting` 必须读 [03-meeting.md](references/03-meeting.md) 中的「两准则」「搜房失败硬门禁」,禁止假设 `roomId`。 diff --git a/skills/multi/dingtalk-calendar/references/03-meeting.md b/skills/multi/dingtalk-calendar/references/03-meeting.md new file mode 100644 index 00000000..ddadf37e --- /dev/null +++ b/skills/multi/dingtalk-calendar/references/03-meeting.md @@ -0,0 +1,56 @@ +# 会议管理(日程与会议室) + +> **lite 速查表**含 `start-conference`、`list-today-meetings`、`check-users-busy`(见 `dws-shared/references/best_practices/_common/lite-recipes.md`)。列表类操作须遵循 [calendar.md](./calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。 +> **听记、会后待办、摘要分享** 见 `dingtalk-minutes` sub-skill 的 `references/07-minutes.md`。 + +## 日程与会议室两准则(强制) + +1. **时段**:用户已明确会议起止时间 → **禁止**自动改期、禁止用闲忙结果或「推荐时段」覆盖用户给定时段;只能在此时段内建日程、订会议室;该时段内无可用或指定资源不可用 → **立刻如实告知**,不得偷偷换时间段再试。 +2. **会议室**:用户点名具体会议室 → **禁止**换其他会议室;在用户给定时段内查无该房 → **立刻告知**。**用户未给出时段时,必须先显式向用户追问具体开始/结束时间;禁止默认用「当前时刻至当日 23:59:59」之类窗口代查。** **`calendar_schedule_meeting.py`**:仅需 `--title`、`--start`、`--end`;先创建日程,再邀请参会人,最后搜房/订房。未给会议室范围时,`--book-room` 为 **单次**无 `--group-id` 的 `room search --available`,取返回的**第一个**会议室并 `room add`;无结果则告警、不删日程。**若用户明确限定楼层/楼宇/园区/分组,应先用 `room list-groups` 解析允许的 `group-id`,再把这些 `group-id` 传给脚本 `--room-group-id`(或手工 `room search --group-id ...`);脚本只会在这些 group 内查找,若无空房则直接返回。对于同一地点(同园区/楼栋/楼层)的会议室,必须优先锁定最相关、最贴近该地点的承载 group;该 group 查无 roomId/空房,即可判定该地点当前时段无可订会议室,**不得**再去别的无关 group 继续碰运气,因为同一地点的会议室只会挂在其所属 group 下。** **在组织内、按早停规则已把应查的分组(或未限范围时的根目录一次查询)全部查完仍无可用会议室时,必须立即向用户说明「当前时段没有可预订的会议室」或「范围内未检索到可用会议室/资源」并收束,禁止继续扩区、换参重试或虚构有房。** 手工 `room search` **禁止**为试出空闲擅自改日或拉长时间窗。 + +### 会议室搜索早停 + +> 专用于 `calendar room list-groups` / `room search` / `room add`;与通用规范「无新参数不重复 search」一致。 + +**`room search --available`**(与传入的 `--start` / `--end` 配对):返回的是在**该整段时段内**可被预订的空闲会议室(不是「有一段空就算」);脚本与用户手工选房均应沿用同一时间窗,避免误以为分段凑满即等价于整段可用。 + +**`dws calendar room search` 合法参数**(与 [calendar.md](./calendar.md) 一致):仅 `--start`、`--end`、`--group-id`(可选)、`--available`(可选)、`--format json` 等;**禁止使用 `--query`**,否则会报 `unknown flag: --query`。 + +**地点归组早停**:若用户给的是同一地点范围(如“西溪园区 C6 楼 3-5 层”或具体楼层/楼栋),先用 `room list-groups` 找到**最相关的承载 group**(通常是该楼层;若楼层下无会议室则为直接挂会议室的上一级)。在这个最相关 group 下查不到有效 `rooms[].roomId` 或空房时,**不得**再跳去别的同级/异地 group 继续搜;同一地点的会议室不会散落在别的 group 里。只有用户明确放宽到别的楼层、楼栋或园区,才能重新解析新的 group 并继续。 + +**用户点名具体会议室(如「C6-4-06-N / 贡嘎山」)**:**不要**尝试 `room search --query "<名称>"`;**禁止**把用户原文(含「C6-4-06-N 贡嘎山」整句)或展示名当作 `room add --rooms` 的 `roomId`。用户输入**几乎从不会是**有效 `roomId`。须先 `dws calendar room list-groups` 定位所在楼层/分组的 `group-id`,再 `dws calendar room search --start "" --end "" --group-id [--available] --format json`,在返回 `rooms[]` 中对 `roomName`、`name` 等与用户表述匹配,**仅**取 JSON 里的 `roomId`(典型为小写十六进制串,长度以返回为准),最后 `dws calendar room add --event --rooms `。该时段无匹配或房间忙 → 如实告知;**禁止**为通过校验而编造、拼接或猜测 `roomId`。 + +### 搜房失败硬门禁(园区/范围搜尽仍无 roomId) + +在用户限定的园区、楼宇、楼层或评测固定分组内,已按早停规则**逐组 `room search` 查完**仍得不到任何有效 `rooms[].roomId`(或无任何空闲房)→ **立即停止**,向用户**明确报错/失败结论**(例如:该时段在指定范围内未检索到可预订会议室或无法获得 roomId),**本回合订房流程结束**。 + +**用户汇报硬门禁**:一旦触发上面的失败条件,**下一条对外输出必须直接面向用户汇报结果**,不得继续在会话里自言自语式地延长推理。允许的后续只有两类: +1. **失败汇报**:明确说明“指定范围/指定会议室在该时段未找到可预订会议室,因此当前无法完成预订” +2. **确认放宽条件**:仅在需要继续推进时,明确问用户是否放宽地点范围、改时间或接受不订会议室 + +以下表述/行为视为**违例**:继续写“让我再试一次”“也许是 Mock/测试环境”“可能存在预设 roomId 映射”“我去别的 group 看看”“我换个时间验证一下”“我先看看脚本/示例还能不能推断出 roomId”。 + +以下行为**一律禁止**(与是否「想多试一次」无关):编造/假设 `roomId` 格式做「预订测试」;在**没有**合法 `roomId` 时调用 `room add` 试探错误详情;拉 `event get` / 日程详情等试图**绕开** `room search` 推断 roomId;换无关园区、扩大关键词、换工具名做未经用户授权的新搜索。 + +**失败后强制回读**:若出现以下任一信号,下一步**必须重新读取本文件本节与 `schedule-meeting` recipe**,不得沿着当前假设继续试: +1. 连续 **2 次** `room search` 空结果/无 `roomId` +2. 任意一次 `roomId invalid` +3. 已开始尝试「换园区 / 换楼栋 / 看 event 详情 / 猜 roomId」 + +回读后只允许二选一: +1. **报错收束**:已搜尽允许范围/整园仍无 `roomId` 或无空房 +2. **用户确认**:明确询问是否放宽范围、换时间,或接受不订会议室 + +| # | 规范 | +|---|------| +| 1 | **一键脚本**:`calendar_schedule_meeting.py` 做「建日程 → 加人 → 可选搜房/订房」;未限范围时可直接 `--book-room`,脚本按根目录单次 `room search --available` 订第一家。**若搜房失败,脚本应输出明确失败原因并返回非零退出码,促使上层立即向用户汇报,而不是继续试探。** | +| 2 | **要限范围/具名**:先 `list-groups` 解析允许的 `group-id`。若用户说的是同一地点(同园区/楼栋/楼层),应优先锁定**最相关的承载 group** 并只查它;该 group 无结果即可按该地点无房收束,不再试别的无关 group。仅当用户明确给出多个允许地点时,才分别对这些 group 各 **1 次** `room search --available` 再 `room add` | +| 3 | **禁止**:无新信息时反复 `--verbose`、反复切 `--available`、父组子组试探、在最相关 group 无结果后改搜别的同级/异地 group、超 100 条后仍根分组或未授权区域全量搜;**禁止**对 `room search` 使用不存在的 `--query` | +| 4 | **`roomId` 门禁**:`room add --rooms` **只能**填 `room search` 返回 JSON 中的 `rooms[].roomId`;**禁止**将用户说的会议室名、编号文案、或「假 UUID / 试数字」当作 `roomId` | +| 5 | **全量无结果即收束**:在用户允许的搜索范围内(含**整园/全 campus** 若用户或评测要求已逐组查尽)仍无任何可用会议室或有效 `roomId` → **直接报错/告知失败并结束订房**,且**下一条消息必须汇报给用户**;**不得**假设 ID、不得用 `room add` 试探、不得绕路查日程、不得继续自说自话分析 Mock/测试环境 | +| 6 | **失败触发回读**:连续 2 次空结果、任意一次 `roomId invalid`、或开始换园区/绕路时 → **必须回读本节**;回读后只允许「报错收束」或「向用户确认是否放宽条件」 | + +| Recipe | 行动指南(固定路线) | +| ------------------ | ------------------- | +| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe,走 lite recipe `start-conference`。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users ] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id ""`。**用户点名具体会议室**:须手工 `dws calendar room search --start "" --end "" --group-id [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event --rooms `;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 | +| reschedule-meeting | 1. `calendar event list --start "<起始ISO>" --end "<结束ISO>"` → 取 `eventId` 2. `calendar event update --id --start "<新起始ISO>" --end "<新结束ISO>"` 更新时间 3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group --text "<变更通知>"` 通知变更 | diff --git a/skills/multi/dingtalk-calendar/references/calendar.md b/skills/multi/dingtalk-calendar/references/calendar.md new file mode 100644 index 00000000..9ea63cf2 --- /dev/null +++ b/skills/multi/dingtalk-calendar/references/calendar.md @@ -0,0 +1,515 @@ +# 日历 (calendar) 命令参考 + +> ✅ **CLI 对齐状态(开源 dws v1.0.30)**:本文档已对齐开源 dws 实际暴露的 4 个子命令组:`event` / `participant` / `room` / `busy`。`attendee`(已统一为 `participant`)、`book` / `attachment` 已从开源 cli 暴露面剔除(钉钉客户端可用,cli 不提供);`event list-mine` 用 `event list --user-id ` 替代。 + +## CLI 命令树与黄金路径 + +- **二级子命令(必选其一)**:`event`(日程)、`participant`(参会人)、`room`(会议室)、`busy`(闲忙)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。 +- **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。 +- **查日程列表**:`dws calendar event list --start "" --end "" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。 +- 注:用户日历本列表(`calendar book`)在开源 v1.0.30 未暴露。 +- **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。 +- **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。 + +## 反模式(禁止) +1. **禁止**执行 `dws calendar` 且不带二级子命令(会刷出大量帮助文本)。合法二级子命令:`event` / `participant` / `room` / `busy`。 +2. **禁止**使用不存在的子命令试探(如臆造 `dws calendar list`);需要日程列表时一律使用 **`dws calendar event list`**(带 `--start` / `--end`,见下文「查询日程列表」示例)。 +3. **禁止**将完整 `--help`/Usage 输出作为「观察」重复提交给模型;若误触,应直接改用本节黄金路径中的合法命令并重试。 +4. **禁止**继续使用已弃用的 `--max-results` 参数 —— 它们仍可被解析但**会被丢弃**,不再透传到 MCP,模型生成命令时也不要再带。 +5. **禁止**为已有日程重新创建日程来预订会议室。若日程已存在(同一会话中刚创建、或用户明确指向某日程),必须使用 `room add --event <已有EVENT_ID> --rooms ` 追加会议室,**绝不能**再调一次 `event create --rooms`(会创建重复日程)。 +6. **禁止**用 `--location` 替代会议室预订。`--location` 是纯文本地点备注字段,填入会议室名称**不会**完成任何预订或占用。预订会议室必须通过 `room add --rooms ` 或 `event create --rooms `,roomId 来自 `room search` 返回;`--location` 与 `--rooms` 是两个独立字段,用途完全不同。 +7. **禁止**只传 `--recurrence-*` 部分 flag **并不是彼此独立的参数**:只传其中一项(比如只改 `--recurrence-count`、只设 `--recurrence-type`)会让服务端收到不完整的 recurrence 结构,CLI 现已前置校验并直接拒绝这类调用。**修改已有周期日程的任何一个循环字段时,都必须重新提供完整的 pattern+range 字段集合**——必要时先 `event get` 读取现有 `recurrence`,再在命令中整体重传。 +8. **禁止**用一条 指令 实现串行调用。比如当用户要求一次性安排多场不同的日程(例如「上午 10 点开项目评审、下午 2 点开复盘会、晚上 7 点聚餐」)时,必须**拆解成 N 条独立的 `event create`,依次串行执行**;每条命令自己写完整的 `--title` / `--start` / `--end`,绝不能把多个标题或多段时间塞进同一行。 + + +## 核心概念 +日历(calendar):日程的容器。每个用户有一个主日历(我的日历,id: primary),还可以订阅公共/团队日历,以及他人共享的日历。 +日程(event):日历中的单个日程,包含起止时间、地点、标题、参会人等属性。支持单次日程和重复日程(有recurrence rule的日程,又称SeriesMaster),遵循RFC5545 iCalendar国际标准。 +日程实例(event instance):日程的具体时间实例,可以通过event list指令查询时间段内的所有实例。1个普通日程和对应1个Instance,而1个重复性日程(SeriesMaster)对应N个Instance(同属一个日程序列)。 + - 同一个日程序列具有相同的iCalUid,并且重复性日程,其eventId和iCalUid的值相同。因此可以通过重复性日程实例的iCalUid得到重复性日程(SeriesMaster)的eventId +重复规则(recurrence rule):定义重复性日程的重复规则。 +参会人(attendee):日程的参与者。常用通讯录工具查询userId,dws contact user search --query "姓名"。 +响应状态(response):参会人对日程的回应,包括:未响应、接受、待定、拒绝。 +忙闲时间(busy):查询用户在指定时间段的忙闲状态,查询会议室在指定时间段的预定状态,用于会议时间协调。 +会议室(room):room是 会议室 ,room可视为日程的资源类参会人,需要加入日程完成预订。注意和location区分,location只是地点,和room不同。 + +## 命令概览 + +### event 相关三级子命令 +``` +# 针对单个日程: 创建 | 修改 | 单查询 | 删除 | 响应日程(接受、暂定、拒绝) +dws calendar event [create|update|get|delete|respond] [flags] +# 按时间范围批量查询 +dws calendar event list [flags] +# 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案 +dws calendar event suggest [flags] +``` + +# 日程中参会人操作:添加 | 删除 | 查询 +dws calendar participant [add|delete|list] [flags] +``` + +### room 相关三级子命令 +``` +# 查询分组 +dws calendar room list-groups [flags] +# 会议室搜索 +dws calendar room search [flags] +# 预定会议室 +dws calendar room add [flags] +# 释放会议室 +dws calendar room delete [flags] +``` +> room是会议室,用于线下开会场景。 + +### busy 相关三级子命令 +``` +# 按用户 / 会议室 + 时间窗查闲忙状态(--users 与 --rooms 至少其一),会议室的忙闲等同于预定记录 +dws calendar busy search [flags] +``` + +# 把已上传到钉盘的文件挂到日程上(不负责上传,只负责挂载) +dws calendar attachment add [flags] +``` + +# 查询当前用户的所有日历,结果范围:用户自己的日历、已订阅的公共/团队日历、他人共享的日历。 +dws calendar book list [flags] +``` +> **说明**: 可以通过 --help 进一步查看指令明细,也可以继续查看下一节 命令总览 + +## 命令总览 + +### 查询日程列表 +``` +Usage: + dws calendar event list [flags] +Example: + dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" +Flags: + --end string 结束时间 ISO-8601 (例如 2026-03-10T18:00:00+08:00) + --start string 开始时间 ISO-8601 (例如 2026-03-10T14:00:00+08:00) +``` + +> `--max-results` 已弃用:MCP 不再支持,CLI 仍接受但参数会被丢弃,结果固定最多返回 100 条。 + +**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。 +**权限**:查询共享日历下的日程时,至少要有reader权限。 + + +### 获取日程详情 +``` +Usage: + dws calendar event get [flags] +Example: + dws calendar event get --id + dws calendar event get --id +Flags: + --id string 日程 ID (必填) +``` + +### 创建日程 +``` +Usage: + dws calendar event create [flags] +Example: + dws calendar event create --title "Q1 复盘会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" + dws calendar event create --title "周会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --attendees userId1,userId2 + dws calendar event create --title "项目评审" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --rooms roomId1,roomId2 # 创建时直接预定会议室 + dws calendar event create --title "每日站会" \ + --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T09:30:00+08:00" \ + --recurrence-type daily --recurrence-interval 1 --recurrence-range-type numbered --recurrence-count 10 +Flags: + --title string 日程标题 (必填,最大2048字符) + --start string 开始时间 ISO-8601 (必填,例如 2026-03-10T14:00:00+08:00) + --end string 结束时间 ISO-8601 (必填,例如 2026-03-10T15:00:00+08:00) + --timezone string 时区 IANA 格式 (例如 Asia/Shanghai,默认 Asia/Shanghai) + --desc string 日程描述 (最大5000字符) + --attendees string 参会人 userId 列表,逗号分隔 (最多500人) 日程组织人自动放入参会人列表,无需传入userId + --open-dingtalk-ids string openDingTalkId 列表,逗号分隔 (与 --attendees 至少传一个) + --rooms string 会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订) + # 以下 --recurrence-* 一旦使用任一 flag,必须同时提供完整的 pattern+range 字段(至少 --recurrence-type、--recurrence-interval(>0) 与 --recurrence-range-type) + # 否则 CLI 会报 "recurrence 结构不完整" 并拒绝执行 + --recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly + --recurrence-interval int 循环间隔 (如 daily 时表示每N天) + --recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填) + --recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填) + --recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填) + --recurrence-first-day-of-week string 一周起始日,默认 sunday + --recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现) + --recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填) + --recurrence-count int 循环次数 (range-type=numbered 时必填) + --rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示 + --location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室) + --free-busy string 此日程的忙碌状态,默认值为busy。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲 +``` + +> **说明**:个人日程也走 `event create`。如果只是给自己安排时间,不传 `--attendees` / `--open-dingtalk-ids` 即可。 + +### 修改日程 +``` +Usage: + dws calendar event update [flags] +Example: + dws calendar event update --id --title "新标题" + dws calendar event update --id --desc "新描述" --timezone Asia/Tokyo + dws calendar event update --id --recurrence-type daily --recurrence-interval 1 \ + --recurrence-range-type numbered --recurrence-count 5 +Flags: + --id string 日程 ID (必填) + --title string 新标题 + --start string 新开始时间 ISO-8601 + --end string 新结束时间 ISO-8601 + --desc string 新描述 (最大5000字符) + --timezone string 时区 IANA 格式 (例如 Asia/Shanghai) + # 以下 --recurrence-* 在 修改周期日程的循环规则时必须**整体**传入:MCP 不合并部分字段,只改其中一项(例如只传 --recurrence-count)会把规则覆盖成不完整状态 + # 若只想微调已有规则,请先 `event get --id ` 读取现有 recurrence,再在本命令重传完整的 pattern+range + --recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly + --recurrence-interval int 循环间隔 (如 daily 时表示每N天) + --recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填) + --recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填) + --recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填) + --recurrence-first-day-of-week string 一周起始日,默认 sunday + --recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现) + --recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填) + --recurrence-count int 循环次数 (range-type=numbered 时必填) + --rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示 + --location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室) + --free-busy string 修改此日程的忙碌状态,无需修改则不传。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲 +``` +> 支持修改标题、描述、时间、地点、忙碌状态等。如需修改会议室,请使用 dws calendar room [add|delete];如需修改参会人,请使用 dws calendar participant [add|delete] + +### 删除日程 + +> **CAUTION:** 不可逆操作 — 所有参会人同步取消,必须先向用户确认。 + +``` +Usage: + dws calendar event delete [flags] +Example: + dws calendar event delete --id --yes +Flags: + --id string 日程 ID (必填) +``` + +### 查看参会人 +``` +Usage: + dws calendar participant list [flags] +Example: + dws calendar participant list --event +Flags: + --event string 日程 ID (必填) +``` + +### 添加参会人 +``` +Usage: + dws calendar participant add [flags] +Example: + dws calendar participant add --event --attendees , + dws calendar participant add --event --attendees --optional +Flags: + --event string 日程 ID (必填) + --attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人) + --optional 参会人可选 (默认必选参会人) +``` + +### 移除参会人 + +> **CAUTION:** 写操作 — 执行前须用户确认。 + +``` +Usage: + dws calendar participant delete [flags] +Example: + dws calendar participant delete --event --attendees --yes +Flags: + --event string 日程 ID (必填) + --attendees string 参会人 userId 列表,逗号分隔 (必填) +``` + +### 搜索会议室 +> 此指令可用于搜索当前用户可用的会议室。**注意**,大部分会议室**仅在工作时间**可用,如果检索时间不在工作时间可能查不到任何结果。 +> 此指令搜索到的会议室结果中,有两个值需要注意: + - customApprovalProcess: true - 表示该会议室设置了自定义审批流程,只能通过客户端完成预订。 + - supportRecurring: true - 表示该会议室支持循环预定;false - 表示不支持循环预定,直接加入到循环日程会失败。 + +``` +Usage: + dws calendar room search [flags] +Example: + dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" + dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --group-id + dws calendar room search --room-name 永澄亭 # 注意:用户即使说「永澄亭会议室」,也应仅传「永澄亭」 + dws calendar room search # 不传 --start/--end 时默认当前时间起 1 小时 +Flags: + --start string 开始时间 ISO-8601 (可选,不传则默认当前时间+1分钟缓冲) + --end string 结束时间 ISO-8601 (可选,不传则默认当前时间+1 小时) + --group-id string 会议室分组ID(可选,留空查根目录;超100条时需按分组查询) + --room-name string 按会议室名称过滤(可选,服务端模糊匹配;传入前必须由调用方精简,只保留核心专名) +``` + +> **时间约束(API 限制)**:`start` 必须是未来的时间(服务端校验:start can not less current time)。 +> - 若传入的 `--start` 早于当前时间,CLI 会自动修正为 `now + 1min`,调用方无需额外处理。 +> - 若传入的 `--end` 早于当前时间,CLI 直接报错——无法检索已过去的时间段。 +> - **最佳实践**:调用方在组装时间参数时应确保 start/end 都是未来时间;若不确定,可省略 `--start`/`--end` 让 CLI 使用默认值(当前时间起 1 小时)。 + +**名称过滤使用规范**:`--room-name` 适用于用户说「预定永澄亭」「约西湖厅」这类按名找会议室的场景。 +- **服务端是模糊匹配,但匹配词越精简命中率越高**,关键疗法:**调用方必须在调用 CLI 前自行精简名称,CLI 不会再做任何删减**。 +- 常见需要剔除的用户口语后缀(仅示例,实际场景由模型自行判断):「会议室」「大会议室」「小会议室」「厅」「房」等。 +- 示例对映: + - 用户:「帮我订永澄亭会议室」 → `--room-name 永澄亭` + - 用户:「西湖厅有空吗」 → `--room-name 西湖厅`(本身就是专名,不删即可) + - 用户:「预定贡嘎山大会议室」 → `--room-name 贡嘎山` + +**优先路径**:当用户给了会议室中文名时,**优先用 `room search --room-name <核心专名> --start <开始时间> --end <结束时间>`**代替「先走 `list-groups`再遍历」的旧流程。`--room-name` 可与 `--group-id` 同时使用,表示「在指定分组内按名称过滤」。若返回空列表,再降级使用 `list-groups` 定位分组再查。 + +**`roomId` 与用户说的话不是一回事**:用户说的「C6-4-06-N 贡嘎山」等是**展示名/编号文案**,**绝不能**直接填进 `room add --rooms`。`--rooms` 只接受上一步 `room search`(或同类接口)返回 JSON 里的 **`rooms[].roomId`**。形态上多为**小写十六进制串**(长度以接口为准,例如 `e6b7b65b8b30fb707afcf6c3b699f028003e6834fdd7fee7`)。含**中文、空格、连字符拼接的楼层编号**、或凭空调 UUID/纯数字「试格式」——一律视为非法,必须先搜房再取返回字段。 + +> 如果知道roomId,想查该会议室的预订记录,直接用dws calendar busy search 指令 + +--- + +### 预定会议室 +``` +Usage: + dws calendar room add [flags] +Example: + dws calendar room add --event --rooms +Flags: + --event string 日程 ID (必填) + --rooms string 会议室 ID 列表 (必填) +``` +> room是会议室,用于线下开会场景。将room加入到日程完成预订 +> 重复性日程,预订会议室时,必须设置 循环结束时间(recurrence-end-date),noEnd 或者 指定循环次数 都无法完成预定。 + + +### 移除会议室 + +> **CAUTION:** 写操作 — 执行前须用户确认。 + +``` +Usage: + dws calendar room delete [flags] +Example: + dws calendar room delete --event --rooms --yes +Flags: + --event string 日程 ID (必填) + --rooms string 会议室 ID 列表 (必填) +``` + +### 会议室分组列表 +``` +Usage: + dws calendar room list-groups [flags] +Example: + dws calendar room list-groups + dws calendar room list-groups --page-size 20 --page-index 0 +Flags: + --page-index string 分页起始位置 (可选,不填默认 0) + --page-size string 页大小 (可选,不填默认 100,超过 100 按 100 处理) +``` + +### 查询用户 / 会议室闲忙状态 +``` +Usage: + dws calendar busy search [flags] +Example: + # 查用户闲忙 + dws calendar busy search --users , \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" + # 查会议室闲忙 + dws calendar busy search --rooms , \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" + # 同时查用户 + 会议室 + dws calendar busy search --users --rooms \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" +Flags: + --end string 结束时间 ISO-8601 (必填) + --start string 开始时间 ISO-8601 (必填) + --users string 用户 ID 列表,逗号分隔 (与 --rooms 至少其一) + --rooms string 会议室 ID 列表,逗号分隔 (与 --users 至少其一) +``` + +> **说明**: +> - `--users` 与 `--rooms` 必须至少指定其一,可以同时指定;CLI 会做前置校验,两者都为空会直接报错。 +> - 查询会议室闲忙前,可先用 `dws calendar room search` 或 `dws calendar room list-groups` 拿到 roomId。 +> - 返回结果中的忙碌时段仅包含粗粒度的时间信息,不包含日程内容细节(如标题、参会人、地点),以保护隐私。 + +### 建议日程时间 +``` +Usage: + dws calendar event suggest [flags] +Example: + dws calendar event suggest --users userId1,userId2 --duration 60 + dws calendar event suggest --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --users userId1 + dws calendar event suggest --users userId1 --duration 30 --timezone Asia/Tokyo +Flags: + --start string 推荐时间范围开始 ISO-8601 (默认当前时间) + --end string 推荐时间范围结束 ISO-8601 (默认次日18点) + --timezone string 时区 IANA 格式 (默认 Asia/Shanghai) + --users string 参会人 userId 列表,逗号分隔 + --duration string 日程持续时间,单位分钟 (默认30) + +> 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案,用于解决会议时间协调问题。 +``` + +### 响应日程 +``` +Usage: + dws calendar event respond [flags] +Example: + dws calendar event respond --id --status accepted + dws calendar event respond --id --status declined + dws calendar event respond --id --status tentative +Flags: + --id string 日程 ID (必填) + --status string 响应状态: needsAction(未操作)|accepted(接受)|declined(拒绝)|tentative(暂定) (必填) +``` + +> **说明**:作为日程参会人,设置自己的响应状态(接受、拒绝、暂定)。`--status` 可选值:`needsAction`(未操作,默认值)、`accepted`(接受)、`declined`(拒绝)、`tentative`(暂定)。 + +## 意图判断 + +用户说"日程/会议/约会/日历": +- 查看 → `event list` +- 详情 → `event get` +- 创建/约/给自己留时间块/个人日程 → `event create`(带参会人时加 `--attendees`,循环日程加 `--recurrence-*`) +- 修改/改时间/改描述 → `event update`(支持修改标题、时间、描述、时区、循环规则) +- 取消/删除 → `event delete` +- 推荐时间/什么时候有空/协调时间 → `event suggest` +- 接受/拒绝/暂定日程 → `event respond` + +用户说"参会人/与会者": +- 查看 → `attendee list` +- 邀请/添加 → `attendee add --attendees `(可选参会人加 `--optional`) +- 移除 → `attendee delete --attendees ` + +用户说"会议室/订会议室": +- 哪个空闲 → `room search` +- 按名找会议室(如「永澄亭」「永澄亭会议室」「约西湖厅」)→ 先在模型层精简名称(剔除「会议室」等通用后缀),再用 `room search --room-name <核心专名>` +- 预订 + - 给已有日程订会议室 → `room add --event <已有EVENT_ID> --rooms ` + - 创建新日程并订会议室 → `event create --rooms`(仅当日程尚不存在时) +- 取消预定 → `room delete` +- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`(可再叠加 `--room-name` 在分组内过滤) + +用户说"有空吗/忙不忙/闲忙": +- 查询用户闲忙 → `busy search --users ` +- 查询会议室闲忙 → `busy search --rooms ` +- 用户 + 会议室一起查 → `busy search --users --rooms ` + +用户说"日程附件/给会议加文件/上传日程材料": +- 添加 → `attachment add`(先用钉盘上传得 fileId,再 `attachment add --files :`) + +用户说"我有几个日历/查所有日历/共享日历本": +- 列表 → `book list`(主日历 id 固定为 `primary`) + +用户说"查下xxx的日程安排": +- 查询是否有共享关系 -> `book list` + - 场景1: 共享日历本相关查询在开源 dws v1.0.30 暂不支持(`--calendar-id` 未暴露);默认查询 primary 主日历 + - 场景2: 共享日历本中没有来自 xxx 的。那么通过 `busy search -- `,查询xxx的忙闲安排 + +## 核心工作流 + +### 创建会议 + 邀请参会人 + 预订会议室 + +`event create` 支持 `--attendees` 在创建时直接指定参会人,**自 calendar MCP v2 起**也支持 `--rooms` 在创建时一并预定会议室;旧流程的「先创建日程再 `room add`」依然有效。 + +**关键区分**:`event create --rooms` 仅在**日程尚不存在**时使用;若日程已存在(同一会话刚创建、或用户指向已有日程),必须走「给已有日程订会议室」流程(见下方),**禁止**重复 `event create`。 + +**方式一:创建时一步完成(仅当日程尚不存在时推荐)** + +```bash +# Step 1: 搜索空闲会议室,记下 roomId +dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json +# 若返回错误(会议室超100条),先查分组再按分组搜索: +# dws calendar room list-groups --format json +# dws calendar room search --start ... --end ... --group-id --format json + +# Step 2: 创建日程时直接指定参会人 + 会议室 +dws calendar event create --title "Q1 复盘会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --attendees userId1,userId2 \ + --rooms --format json +``` + +**方式二:先创建日程,再单独添加参会人 / 会议室** + +```bash +# Step 1: 创建日程 — 提取 eventId +dws calendar event create --title "Q1 复盘会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json + +# Step 2: 添加参会人(必须用 Step 1 返回的 eventId) +dws calendar participant add --event --attendees userId1,userId2 --format json + +# Step 3: 搜索空闲会议室 +dws calendar room search --start ... --end ... --format json + +# Step 4: 预定会议室 +dws calendar room add --event --rooms --format json +``` + +### 给已存在的日程加附件 + +```bash +# Step 1: 用钉盘上传文件,得到 fileId(参见 dws drive 系列命令) +# Step 2: 把附件挂到指定日程 +dws calendar attachment add --event --files :report.pdf,:slides.pptx --format json +``` + +### 查看日程列表 + +```bash +dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `event create` | `eventId` | attendee/room/attachment 操作的 --event | +| `event list` | `events[].eventId` | event get/update/delete/respond 的 --id | +| `event suggest` | 推荐的时间段 | event create 的 --start/--end | +| `event respond` | 响应结果 | — | +| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms | +| `room list-groups` | `groups[].groupId` | room search 的 --group-id | + +| 钉盘上传 | 文件 `fileId` | attachment add 的 --files `:` | + +## 注意事项 + +- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601 +- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai` +- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --attendees ...` 单独添加 +- `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人) +- 添加参会人时可通过 `--optional` 设为可选参会人(默认必选) +- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用 +- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add` +- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询 +- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串) +- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误) +- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId +- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` +- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `:` 元素逗号分隔 +- **日历本**:开源 dws v1.0.30 `event list` / `event get` 不接受 `--calendar-id`,固定查 primary 主日历 +- **已弃用入参**:`--max-results`(event list / list-mine)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [calendar_today_agenda.py](../scripts/calendar_today_agenda.py) | 查看今天/明天/本周日程安排 | `python calendar_today_agenda.py today` | +| [calendar_schedule_meeting.py](../scripts/calendar_schedule_meeting.py) | 一键创建日程+添加参会人+预定会议室;搜房失败时输出明确原因并返回非零退出码 | `python calendar_schedule_meeting.py --title "复盘会" --start "2026-03-15T14:00" --end "2026-03-15T15:00" --users userId1 --book-room` | +| [calendar_free_slot_finder.py](../scripts/calendar_free_slot_finder.py) | 查询多人共同空闲时段 | `python calendar_free_slot_finder.py --users userId1,userId2 --date 2026-03-15` | + +## 相关产品 + +- `dingtalk-conference` (`references/conference.md`) — 视频会议发起/预约/邀请入会/会中控制;日历日程、参会人、会议室管理仍用 calendar +- `dingtalk-contact` (`references/contact.md`) — 搜索同事 userId,用于 attendee add --attendees diff --git a/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py b/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py new file mode 100644 index 00000000..01c9f637 --- /dev/null +++ b/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +查询多人共同空闲时段,推荐最佳会议时间 + +用法: + python calendar_free_slot_finder.py \ + --users userId1,userId2,userId3 \ + --date 2026-03-15 \ + --duration 60 + + python calendar_free_slot_finder.py \ + --users userId1,userId2 \ + --date 2026-03-15 \ + --start-hour 9 --end-hour 18 \ + --duration 30 --dry-run +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any, Optional, Tuple + +TZ = timezone(timedelta(hours=8)) +SLOT_STEP_MIN = 30 + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def fmt_iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + + +def parse_busy_intervals( + data: Any, +) -> List[Tuple[datetime, datetime]]: + intervals = [] + if not data: + return intervals + items = [] + if isinstance(data, list): + items = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, list): + items = inner + elif isinstance(inner, dict): + for user_data in inner.values(): + if isinstance(user_data, list): + items.extend(user_data) + elif isinstance(user_data, dict): + items.extend( + user_data.get('busyTimes', []) + ) + for item in items: + start_str = item.get('startTime') or item.get('start', '') + end_str = item.get('endTime') or item.get('end', '') + if not start_str or not end_str: + continue + for fmt in ( + '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M%z', + ): + try: + s = datetime.strptime(start_str, fmt) + e = datetime.strptime(end_str, fmt) + if s.tzinfo is None: + s = s.replace(tzinfo=TZ) + if e.tzinfo is None: + e = e.replace(tzinfo=TZ) + intervals.append((s, e)) + break + except ValueError: + continue + return intervals + + +def find_free_slots( + day_start: datetime, day_end: datetime, + busy: List[Tuple[datetime, datetime]], + duration_min: int, +) -> List[Tuple[datetime, datetime]]: + busy_sorted = sorted(busy, key=lambda x: x[0]) + merged: List[Tuple[datetime, datetime]] = [] + for s, e in busy_sorted: + if merged and s <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], e)) + else: + merged.append((s, e)) + + free: List[Tuple[datetime, datetime]] = [] + cursor = day_start + for bs, be in merged: + if cursor < bs: + gap = (bs - cursor).total_seconds() / 60 + if gap >= duration_min: + free.append((cursor, bs)) + cursor = max(cursor, be) + if cursor < day_end: + gap = (day_end - cursor).total_seconds() / 60 + if gap >= duration_min: + free.append((cursor, day_end)) + return free + + +def main(): + parser = argparse.ArgumentParser( + description='查询多人共同空闲时段' + ) + parser.add_argument( + '--users', required=True, help='用户 ID 列表,逗号分隔' + ) + parser.add_argument( + '--date', required=True, help='查询日期 YYYY-MM-DD' + ) + parser.add_argument( + '--duration', type=int, default=60, + help='会议时长(分钟),默认 60', + ) + parser.add_argument( + '--start-hour', type=int, default=9, + help='工作日开始小时,默认 9', + ) + parser.add_argument( + '--end-hour', type=int, default=18, + help='工作日结束小时,默认 18', + ) + parser.add_argument( + '--dry-run', action='store_true', help='仅显示命令' + ) + args = parser.parse_args() + + try: + date = datetime.strptime(args.date, '%Y-%m-%d') + except ValueError: + print('错误:日期格式应为 YYYY-MM-DD') + sys.exit(1) + + day_start = date.replace( + hour=args.start_hour, tzinfo=TZ + ) + day_end = date.replace(hour=args.end_hour, tzinfo=TZ) + + data = run_dws([ + 'calendar', 'busy', 'search', + '--users', args.users, + '--start', fmt_iso(day_start), + '--end', fmt_iso(day_end), + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + return + + busy = parse_busy_intervals(data) + free = find_free_slots(day_start, day_end, busy, args.duration) + + users_list = args.users.split(',') + print(f"\n🕐 空闲时段查询 ({args.date})") + print(f" 参与人: {len(users_list)} 人") + print(f" 会议时长: {args.duration} 分钟") + print(f" 工作时间: {args.start_hour}:00 ~ " + f"{args.end_hour}:00") + print('=' * 50) + + if not free: + print(' ❌ 该日无共同空闲时段') + return + + print(f"\n✅ 找到 {len(free)} 个可用时段:\n") + for i, (s, e) in enumerate(free, 1): + gap_min = int((e - s).total_seconds() / 60) + label = '⭐ 推荐' if i == 1 else f' 备选{i-1}' + print(f" {label} {s.strftime('%H:%M')} ~ " + f"{e.strftime('%H:%M')} ({gap_min}分钟)") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-calendar/scripts/calendar_schedule_meeting.py b/skills/multi/dingtalk-calendar/scripts/calendar_schedule_meeting.py new file mode 100644 index 00000000..f5ce05b0 --- /dev/null +++ b/skills/multi/dingtalk-calendar/scripts/calendar_schedule_meeting.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +一键创建日程(可选:带参与者 + 预定空闲会议室) + +流程: + 1. 若需预定会议室 (--book-room),先搜索空闲会议室;无可用则提前报错退出 + 2. 使用 event create 一次性完成日程创建 + 添加参与者 + 预定会议室 + +用法: + python calendar_schedule_meeting.py \ + --title "Q1 复盘会" \ + --start "2026-03-15T14:00" \ + --end "2026-03-15T15:00" \ + --users userId1,userId2 \ + --book-room + + python calendar_schedule_meeting.py --dry-run \ + --title "测试" --start "2026-03-15T14:00" --end "2026-03-15T15:00" +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta, timezone +from typing import List, Any, Optional, Tuple + +TZ = timezone(timedelta(hours=8)) + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def normalize_time(time_str: str) -> str: + for fmt in ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M', + '%Y-%m-%dT%H:%M:%S'): + try: + dt = datetime.strptime(time_str, fmt) + dt = dt.replace(tzinfo=TZ) + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + except ValueError: + continue + if '+' in time_str or time_str.endswith('Z'): + return time_str + raise ValueError(f"无法解析时间:{time_str}") + + +def parse_group_ids(raw: str) -> List[str]: + if raw is None: + return [] + return [part.strip() for part in raw.split(',') if part.strip()] + + +def extract_room_candidates(payload: Any) -> Tuple[List[dict], str]: + candidates: Any = [] + if isinstance(payload, list): + candidates = payload + elif isinstance(payload, dict): + if isinstance(payload.get('rooms'), list): + candidates = payload.get('rooms', []) + elif isinstance(payload.get('result'), dict): + nested = payload.get('result', {}) + if isinstance(nested.get('rooms'), list): + candidates = nested.get('rooms', []) + elif isinstance(nested.get('result'), list): + candidates = nested.get('result', []) + elif isinstance(payload.get('result'), list): + candidates = payload.get('result', []) + + if not isinstance(candidates, list): + return [], '返回结构中未找到会议室列表' + + valid_rooms: List[dict] = [] + placeholder_count = 0 + for item in candidates: + if not isinstance(item, dict): + continue + if item.get('roomId') or item.get('id'): + valid_rooms.append(item) + continue + if item.get('labels') is None and len(item) == 1: + placeholder_count += 1 + + if valid_rooms: + return valid_rooms, f'返回 {len(valid_rooms)} 个有效会议室' + if placeholder_count: + return [], '仅返回占位结果(如 labels:null),无有效 roomId' + if candidates: + return [], '返回了对象列表,但均不含有效 roomId' + return [], '未返回任何会议室' + + +def main(): + parser = argparse.ArgumentParser( + description='一键创建日程(可选:带参与者 + 预定会议室)' + ) + parser.add_argument('--title', required=True, help='日程标题') + parser.add_argument('--start', required=True, help='开始时间') + parser.add_argument('--end', required=True, help='结束时间') + parser.add_argument('--desc', default='', help='日程描述') + parser.add_argument('--users', default='', help='参与者 userId,逗号分隔') + parser.add_argument( + '--book-room', action='store_true', help='自动搜索并预定空闲会议室' + ) + parser.add_argument( + '--room-group-id', default='', + help='允许搜索的 groupId;同一地点请只传最相关 group,多个仅用于用户明确允许的多个地点' + ) + parser.add_argument( + '--dry-run', action='store_true', help='仅显示命令' + ) + args = parser.parse_args() + + try: + start_iso = normalize_time(args.start) + end_iso = normalize_time(args.end) + except ValueError as e: + print(f"错误:{e}") + sys.exit(1) + + # ── Step 1: 若需预定会议室,先搜索空闲会议室 ────────────────── + room_id: Optional[str] = None + room_name: Optional[str] = None + + if args.book_room: + print('🏢 搜索空闲会议室...') + group_ids = parse_group_ids(args.room_group_id) + search_scopes = group_ids or [None] + selected_room = None + failure_reasons: List[str] = [] + + for group_id in search_scopes: + scope_label = f'group {group_id}' if group_id else '根目录' + print(f' - 查询范围: {scope_label}') + search_args = [ + 'calendar', 'room', 'search', + '--start', start_iso, + '--end', end_iso, + '--available', + '--format', 'json', + ] + if group_id: + search_args.extend(['--group-id', group_id]) + rooms_data = run_dws(search_args, dry_run=args.dry_run) + + if args.dry_run: + continue + if not rooms_data: + failure_reasons.append(f'{scope_label}: room search 执行失败') + continue + + rooms, detail = extract_room_candidates(rooms_data) + if rooms: + selected_room = rooms[0] + break + failure_reasons.append(f'{scope_label}: {detail}') + + if not args.dry_run: + if selected_room: + room_id = selected_room.get('roomId') or selected_room.get('id') + room_name = selected_room.get('roomName') or selected_room.get('name') + print(f' ✓ 找到空闲会议室: {room_name} ({room_id})') + else: + print(f' ✗ {start_iso} ~ {end_iso} 时段内无可用会议室') + for reason in failure_reasons: + print(f' - {reason}') + print(' 请向用户汇报失败,或询问是否放宽范围/改时间。') + sys.exit(2) + + # ── Step 2: 一次性创建日程(含参与者 + 会议室) ──────────────── + print('\n📅 创建日程...') + create_args = [ + 'calendar', 'event', 'create', + '--title', args.title, + '--start', start_iso, + '--end', end_iso, + '--format', 'json', + ] + if args.desc: + create_args.extend(['--desc', args.desc]) + if args.users: + create_args.extend(['--attendees', args.users]) + if room_id: + create_args.extend(['--rooms', str(room_id)]) + + result = run_dws(create_args, dry_run=args.dry_run) + if not result: + sys.exit(1) + + # 解析响应 + event_id = None + if not args.dry_run and isinstance(result, dict): + # MCP 响应通常嵌套在 result 字段内: {"result": {"id": "..."}} + inner = result.get('result', result) + if isinstance(inner, dict): + event_id = inner.get('eventId') or inner.get('id') + else: + event_id = result.get('eventId') or result.get('id') + + # 输出结果摘要 + parts = [] + if event_id: + parts.append(f'eventId: {event_id}') + if args.users: + parts.append(f'参与者: {args.users}') + if room_name: + parts.append(f'会议室: {room_name}') + detail_str = f" ({', '.join(parts)})" if parts else '' + print(f' ✓ 日程已创建{detail_str}') + + print('\n✅ 完成!') + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-calendar/scripts/calendar_today_agenda.py b/skills/multi/dingtalk-calendar/scripts/calendar_today_agenda.py new file mode 100644 index 00000000..7e9bd049 --- /dev/null +++ b/skills/multi/dingtalk-calendar/scripts/calendar_today_agenda.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +查看今天/明天/本周的日程安排 + +用法: + python calendar_today_agenda.py # 今天 + python calendar_today_agenda.py today # 今天 + python calendar_today_agenda.py tomorrow # 明天 + python calendar_today_agenda.py week # 本周 + python calendar_today_agenda.py --dry-run # 仅显示命令 +""" + +import sys +import json +import subprocess +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any, Optional + +TZ = timezone(timedelta(hours=8)) + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def get_range(scope: str): + now = datetime.now(TZ) + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + if scope == 'today': + return today, today + timedelta(days=1) + elif scope == 'tomorrow': + t = today + timedelta(days=1) + return t, t + timedelta(days=1) + elif scope == 'week': + ws = today - timedelta(days=today.weekday()) + return ws, ws + timedelta(days=7) + return today, today + timedelta(days=1) + + +def fmt_iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + + +def fmt_time(iso_str: str) -> str: + if not iso_str: + return '??:??' + try: + for fmt in ('%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S'): + try: + dt = datetime.strptime(iso_str, fmt) + return dt.strftime('%H:%M') + except ValueError: + continue + return iso_str[:16] + except Exception: + return iso_str[:16] + + +def main(): + dry_run = '--dry-run' in sys.argv + args = [a for a in sys.argv[1:] if a != '--dry-run'] + scope = args[0] if args else 'today' + if scope not in ('today', 'tomorrow', 'week'): + print(__doc__) + sys.exit(1) + + start, end = get_range(scope) + data = run_dws([ + 'calendar', 'event', 'list', + '--start', fmt_iso(start), + '--end', fmt_iso(end), + '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return + + events = [] + if isinstance(data, list): + events = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + events = inner.get('events', []) + elif isinstance(inner, list): + events = inner + + label = {'today': '今天', 'tomorrow': '明天', 'week': '本周' + }.get(scope, scope) + print(f"\n📅 {label}日程 ({start.strftime('%m-%d')} ~ " + f"{end.strftime('%m-%d')})") + print('=' * 50) + + if not events: + print(' ✅ 暂无日程,自由安排!') + return + + for e in events: + if not isinstance(e, dict): + print(f" 🕐 {e}") + continue + title = e.get('summary') or e.get('title', '无标题') + s = e.get('start', {}) + ed = e.get('end', {}) + start_t = fmt_time( + s.get('dateTime', '') if isinstance(s, dict) else str(s) + ) + end_t = fmt_time( + ed.get('dateTime', '') if isinstance(ed, dict) else str(ed) + ) + loc = e.get('location', {}) + loc_str = (loc.get('displayName', '') + if isinstance(loc, dict) else str(loc or '')) + line = f" 🕐 {start_t}-{end_t} {title}" + if loc_str: + line += f" 📍{loc_str}" + print(line) + + print(f"\n合计: {len(events)} 场日程") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md new file mode 100644 index 00000000..24ebf51a --- /dev/null +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dingtalk-chat +description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉群聊 / 消息 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "发消息给张三" | `dws chat message send --open-dingtalk-id --title "<标题>" --text "<内容>"` | +| "发到XX群" | `dws chat search --query "<群名>"` → `dws chat message send --group --title "<标题>" --text "<内容>"` | +| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` | +| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,确认目标后加 --yes)| +| "@我消息" / "查群聊记录" | `dws chat message list` | +| "用机器人发消息" | `dws chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` | +| "Webhook 推一条" | `dws chat message send-by-webhook --token --title "<标题>" --text "<内容>"` | +| "撤回机器人消息" | `dws chat message recall-by-bot --robot-code --group --keys `(只能撤回机器人发的;撤回普通用户消息开源 dws v1.0.30 暂不支持)| + +> **注**:v1.0.30 起 `chat message send / send-by-bot / send-by-webhook` 全部强制 `--title` 必填(单聊群聊都要)。 + +## 跨产品协作 + +- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId` +- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py ""` 提取 mediaId → 再用 `--media-id` +- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-ding` +- 发邮件 → 切到 `dingtalk-mail` diff --git a/skills/multi/dingtalk-chat/references/01-messaging.md b/skills/multi/dingtalk-chat/references/01-messaging.md new file mode 100644 index 00000000..7932c5a8 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/01-messaging.md @@ -0,0 +1,15 @@ +# 消息沟通 + +> **安全红线**:收件人搜索返回多条/零条结果、或名称不完全匹配时,**必须列出候选项让用户确认**,严禁自动选择第一条。即使支持机器人消息撤回 `chat message recall-by-bot`,对方可能已读,发错代价高。禁止自行发送"测试消息"验证通道。 + +> lite recipe 见 `dws-shared/references/best_practices/_common/lite-recipes.md`。 + +| Recipe | 行动指南(固定路线) | +|--------|-------------------| +| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 | +| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list-direct --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 | +| escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 `dingtalk-ding/references/ding.md`)
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` | +| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code --chats , --title "<标题>" --text "<内容>"`
单群:1. `chat bot search` → 取 `robotCode`
2. `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` | +| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group --time "<起始时间>"` 拉源消息
2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | +| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)
用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 | +| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)
触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`
**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact/references/contact.md` 意图判断)
翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`
按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id --start --end ` | diff --git a/skills/multi/dingtalk-chat/references/chat-emoji-list.md b/skills/multi/dingtalk-chat/references/chat-emoji-list.md new file mode 100644 index 00000000..338710fd --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat-emoji-list.md @@ -0,0 +1,209 @@ +# 钉钉默认表情列表(emoji 回应可用) + +> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。 +> +> 使用规则: +> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji ` 贴 emoji 回应 +> - 用户描述的表情未命中下表 → 先 `chat message create-text-emotion` 创建文字表情获取 emotionId,再 `chat message add-text-emotion` 贴文字表情回应 + +| # | emotionId | name | en_US | +|---|-----------|------|-------| +| 1 | emotion_001 | 微笑 | Smile | +| 2 | emotion_099 | 可爱 | Lovely | +| 3 | emotion_002 | 憨笑 | Wow | +| 4 | emotion_003 | 色 | Yum | +| 5 | emotion_004 | 发呆 | Dazed | +| 6 | emotion_005 | 老板 | Boss | +| 7 | emotion_036 | 傻笑 | Oops | +| 8 | emotion_006 | 流泪 | Sob | +| 9 | emotion_007 | 害羞 | Shy | +| 10 | emotion_008 | 闭嘴 | Silence | +| 11 | emotion_009 | 睡 | Sleepy | +| 12 | emotion_010 | 大哭 | Cry | +| 13 | emotion_011 | 尴尬 | Awkward | +| 14 | emotion_080 | 感谢 | Thanks | +| 15 | emotion_204 | 拒绝 | SayNo | +| 16 | emotion_078 | 赞 | Like | +| 17 | emotion_024 | 鼓掌 | Clap | +| 18 | emotion_105 | 打招呼 | Hi | +| 19 | emotion_159 | 666 | 666 | +| 20 | emotion_079 | 抱拳 | Salute | +| 21 | emotion_025 | 握手 | Shake | +| 22 | emotion_023 | OK | OK | +| 23 | emotion_033 | 胜利 | Peace | +| 24 | emotion_142 | 向左 | Left | +| 25 | emotion_143 | 向右 | Right | +| 26 | emotion_144 | 向上 | Up | +| 27 | emotion_145 | 向下 | Down | +| 28 | emotion_185 | 来呀 | Come | +| 29 | emotion_155 | 一点点 | ALittle | +| 30 | emotion_179 | 捏住 | Pinch | +| 31 | emotion_140 | 比心 | FingerHeart | +| 32 | emotion_106 | 送花花 | Flower | +| 33 | emotion_178 | 加油干 | MakeEffort | +| 34 | emotion_013 | 调皮 | Tongueout | +| 35 | emotion_014 | 大笑 | Laugh | +| 36 | emotion_015 | 惊讶 | Scowl | +| 37 | emotion_016 | 流汗 | Sweat | +| 38 | emotion_017 | 奋斗 | Fight | +| 39 | emotion_018 | 口罩 | Mask | +| 40 | emotion_019 | 生病 | Sick | +| 41 | emotion_020 | 吐 | Barf | +| 42 | emotion_021 | 难过 | Bummed | +| 43 | emotion_022 | 抓狂 | Crazy | +| 44 | emotion_026 | 右哼哼 | Humph | +| 45 | emotion_027 | 太阳 | Sunny | +| 46 | emotion_028 | 月亮 | Moon | +| 47 | emotion_029 | 强 | Thumbsup | +| 48 | emotion_030 | 弱 | Thumbsdown | +| 49 | emotion_031 | 彩带 | Tada | +| 50 | emotion_032 | 蛋糕 | Cake | +| 51 | emotion_034 | 骷髅 | Skull | +| 52 | emotion_035 | 撇嘴 | Pout | +| 53 | emotion_037 | 鄙视 | Dislike | +| 54 | emotion_038 | 嘘 | Shhh | +| 55 | emotion_040 | 思考 | Hmm… | +| 56 | emotion_041 | 亲亲 | Kiss | +| 57 | emotion_042 | 无奈 | Disappointed | +| 58 | emotion_043 | 感冒 | Pollution | +| 59 | emotion_044 | 对不起 | Sorry | +| 60 | emotion_045 | 再见 | Wave | +| 61 | emotion_046 | 投降 | GiveUp | +| 62 | emotion_047 | 哼 | Grumpy | +| 63 | emotion_048 | 欠扁 | FaceSlap | +| 64 | emotion_049 | 拜托 | Please | +| 65 | emotion_050 | 可怜 | Aww… | +| 66 | emotion_051 | 舒服 | Relax | +| 67 | emotion_052 | 爱意 | Romantic | +| 68 | emotion_054 | 财迷 | MoneyMoney | +| 69 | emotion_055 | 迷惑 | Puzzled | +| 70 | emotion_056 | 委屈 | Worried | +| 71 | emotion_057 | 灵感 | Idea | +| 72 | emotion_058 | 天使 | Angel | +| 73 | emotion_059 | 鬼脸 | SillyFace | +| 74 | emotion_060 | 凄凉 | Phew | +| 75 | emotion_061 | 郁闷 | Tired | +| 76 | emotion_063 | 坏笑 | Trick | +| 77 | emotion_064 | 算账 | SoMuch | +| 78 | emotion_206 | PK | PK | +| 79 | emotion_066 | 忍者 | Sneaky | +| 80 | emotion_039 | 衰 | Grr | +| 81 | emotion_067 | 炸弹 | Uh-Oh | +| 82 | emotion_081 | 笑哭 | LaughAndCry | +| 83 | emotion_082 | 嘿嘿 | Smirk | +| 84 | emotion_083 | 捂脸哭 | Facepalm | +| 85 | emotion_084 | 抠鼻 | NosePick | +| 86 | emotion_085 | 流鼻血 | BloodyNose | +| 87 | emotion_090 | 呲牙 | Grin | +| 88 | emotion_091 | 吃瓜 | EatingMelon | +| 89 | emotion_092 | 彩虹 | Rainbow | +| 90 | emotion_098 | 耶 | Yeah | +| 91 | emotion_012 | 发怒 | Steamed | +| 92 | emotion_100 | 捂眼睛 | CannotLook | +| 93 | emotion_101 | 推眼镜 | PushGlasses | +| 94 | emotion_102 | 暗中观察 | Peep | +| 95 | emotion_103 | 脑暴 | Brainstorming | +| 96 | emotion_112 | 冷笑 | Distressed | +| 97 | emotion_208 | 热 | HotFace | +| 98 | emotion_113 | 开心 | Happy | +| 99 | emotion_114 | 惊喜 | Surprised | +| 100 | emotion_115 | 回头 | LookBack | +| 101 | emotion_116 | 白眼 | RollEyes | +| 102 | emotion_117 | 一团乱麻 | Overwhelmed | +| 103 | emotion_149 | 黑眼圈 | DarkCircle | +| 104 | emotion_225 | 裂开 | Broken | +| 105 | emotion_156 | 恭喜 | Congrats | +| 106 | emotion_160 | 费解 | Confuse | +| 107 | emotion_167 | 收到 | RogerThat | +| 108 | emotion_186 | 快来 | ComeOn | +| 109 | emotion_086 | 敲打 | Hammer | +| 110 | emotion_176 | 捧脸 | HoldFace | +| 111 | emotion_194 | Get | Get | +| 112 | emotion_207 | 客服 | CustomerService | +| 113 | emotion_210 | AR | AR | +| 114 | emotion_221 | 小蜜蜂 | Bee | +| 115 | emotion_211 | 虎虎生威 | MajesticTiger | +| 116 | emotion_222 | 兔飞猛进 | Rabbit | +| 117 | emotion_226 | 龙头老大 | Dragon Face | +| 118 | emotion_236 | 蛇来运转 | Good luck | +| 119 | emotion_238 | 马上来财 | Wealth is coming | +| 120 | emotion_093 | 专注 | Concentrate | +| 121 | emotion_166 | 忙疯了 | CrazyBusy | +| 122 | emotion_187 | 等一等 | Wait | +| 123 | emotion_227 | 一脸苦笑 | Wry Smile | +| 124 | emotion_228 | 王之蔑视 | Unamused | +| 125 | emotion_229 | 洪荒之力 | Amazing | +| 126 | emotion_234 | 向左看 | Thinking | +| 127 | emotion_235 | 向右看 | Pondering | +| 128 | emotion_230 | YYDS | YYDS | +| 129 | emotion_231 | 这边请 | ThisWayPlease | +| 130 | emotion_232 | 弹射下班 | OffDuty | +| 131 | emotion_233 | 退退退 | Back | +| 132 | emotion_188 | 在吗 | Hello? | +| 133 | emotion_189 | 让人头大 | Hard | +| 134 | emotion_089 | 摊手 | Smugshrug | +| 135 | emotion_088 | 抱抱 | Hug | +| 136 | emotion_158 | 举手 | RaiseHand | +| 137 | emotion_177 | 开车 | Driving | +| 138 | emotion_191 | 抱大腿 | Follow | +| 139 | emotion_087 | 跪了 | YouWin | +| 140 | emotion_162 | 鞠躬 | Bow | +| 141 | emotion_180 | 选我 | PickMe | +| 142 | emotion_209 | 元气满满 | FullOfVitality | +| 143 | emotion_168 | 会议 | Meeting | +| 144 | emotion_095 | 猫咪 | Kitty | +| 145 | emotion_094 | 二哈 | Doggy | +| 146 | emotion_097 | 狗子 | Puppy | +| 147 | emotion_111 | 三多 | SanDuo | +| 148 | emotion_153 | 承让 | LetMeWin | +| 149 | emotion_154 | 撒花 | Celebration | +| 150 | emotion_070 | 礼物 | Present | +| 151 | emotion_104 | 生日快乐 | Birthday | +| 152 | emotion_071 | 爱心 | Love | +| 153 | emotion_072 | 心碎 | BrokenHeart | +| 154 | emotion_073 | 嘴唇 | Lips | +| 155 | emotion_074 | 鲜花 | Rose | +| 156 | emotion_075 | 残花 | Wilted | +| 157 | emotion_077 | 干杯 | Cheers | +| 158 | emotion_151 | 咖啡 | Coffee | +| 159 | emotion_152 | 奶茶 | MilkTea | +| 160 | emotion_202 | 茶 | Tea | +| 161 | emotion_218 | OKR | OKR | +| 162 | emotion_109 | KPI | KPI | +| 163 | emotion_108 | 100分 | 100 | +| 164 | emotion_110 | 对勾 | Check | +| 165 | emotion_192 | 打叉 | Wrong | +| 166 | emotion_174 | 气泡 | Bubble | +| 167 | emotion_157 | 加一 | PlusOne | +| 168 | emotion_193 | Done | Done | +| 169 | emotion_146 | 钉子 | Staple | +| 170 | emotion_076 | 出差 | BusinessTrip | +| 171 | emotion_181 | 高铁 | HighSpeedTrain | +| 172 | emotion_184 | 火箭 | Rocket | +| 173 | emotion_068 | 邮件 | Mail | +| 174 | emotion_163 | 文档 | Document | +| 175 | emotion_164 | 演示 | Presentation | +| 176 | emotion_165 | 表格 | Sheet | +| 177 | emotion_213 | 废纸篓 | Wastebasket | +| 178 | emotion_237 | 手机 | MobilePhone | +| 179 | emotion_203 | 时间 | Time | +| 180 | emotion_217 | 静音 | mute | +| 181 | emotion_201 | 公文包 | Briefcase | +| 182 | emotion_214 | 地球 | Earth | +| 183 | emotion_215 | 碳减排 | CarbonReduction | +| 184 | emotion_216 | 回收标志 | RecyclingSymbol | +| 185 | emotion_205 | 幼苗 | Seedling | +| 186 | emotion_096 | 红包 | RedPacket | +| 187 | emotion_150 | 锦鲤 | LuckyDog | +| 188 | emotion_148 | 福 | Luck | +| 189 | emotion_198 | 灯笼 | Lantern | +| 190 | emotion_199 | 爆竹 | Firecrackers | +| 191 | emotion_197 | 烟花 | Fireworks | +| 192 | emotion_195 | 恭喜发财 | Prosperity | +| 193 | emotion_161 | 月饼 | MoonCake | +| 194 | emotion_173 | 鸡腿 | ChickenLeg | +| 195 | emotion_169 | 休假 | Vacation | +| 196 | emotion_175 | 火 | Hot | +| 197 | emotion_223 | 点赞 | LikeHeartAndTripleSix | +| 198 | emotion_196 | 平安健康 | Peace&Health | +| 199 | emotion_200 | 定胜 | Victory | diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md new file mode 100644 index 00000000..9bb140b5 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -0,0 +1,1421 @@ +# 会话与群聊 (chat) 命令参考 + +## 命令总览 + +### group (群组管理) + +| 子命令 | 用途 | +|-------|------| +| `group create` | 创建内部群 | +| `group members list` | 查看群成员列表 | +| `group members add` | 添加群成员 | +| `group members remove` | 移除群成员(⚠️ 危险操作) | +| `group members add-bot` | 添加机器人到群 | +| `group rename` | 修改群名称 | +| `group get-by-group-id` | 按数字群号获取会话信息(群号转 openConversationId) | +| `group transfer-owner` | 转让群主 | +| `group invite-url` | 获取群邀请链接 | +| `group quit` | 退出群聊 | +| `group update-icon` | 更新群头像 | +| `group update-settings` | 更新群设置 | +| `group set-admin` | 设置/取消群管理员 | +| `group member-role` | 群自定义角色(add / list / update / remove / set-user / remove-user / query-user) | + +### message (会话消息管理) + +| 子命令 | 用途 | +|-------|------| +| `message send` | 以当前用户身份发消息(群聊或单聊) | +| `message recall-by-bot` | 撤回机器人发出的消息(开源 dws v1.0.30 不提供撤回普通用户消息的命令)| +| `message send-by-bot` | 机器人发消息(群聊或批量单聊) | +| `message recall-by-bot` | 机器人撤回消息 | +| `message send-by-webhook` | 自定义机器人 Webhook 发消息 | +| `message send-card` / `update-card` | 创建并推送流式卡片,配合更新内容 | +| `message reply` | 引用回复消息(单聊/群聊均可) | +| `message forward` | 转发单条消息(源/目标会话均支持单聊/群聊) | +| `message list` | 拉取群聊或单聊会话消息 | +| `message list-all` | 按时间范围拉取当前用户所有会话消息 | +| `message list-topic-replies` | 拉取群话题回复消息列表 | +| `message list-by-sender` | 搜索指定发送者的消息 | +| `message list-mentions` | 拉取 @我 的消息 | +| `message list-focused` | 拉取特别关注人的消息 | +| `message list-unread-conversations` | 获取未读会话列表 | +| `message list-by-ids` | 按消息 ID 批量获取消息 | +| `message search` | 按关键词搜索消息 | +| `message search-advanced` | 多维度搜索消息(推荐首选,严格超集) | +| `message query-send-status` | 查询消息发送状态(按 openTaskId) | +| `message query-read-status` | 查询消息已读/未读状态 | +| `message add-emoji` / `remove-emoji` | 给消息添加/移除 emoji 表情回应 | +| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情回应 | +| `message create-text-emotion` | 创建文字表情模板(返回 emotionId 供 add-text-emotion 使用) | + +### chat 顶级命令 + +| 子命令 | 用途 | +|-------|------| +| `search` | 根据关键词搜索群聊 | +| `search-common` | 搜索共同群 | +| `conversation-info` | 获取会话基础信息(含 newCSpaceIdIM) | +| `list-top-conversations` | 拉取置顶会话列表 | +| `set-top` | 设置/取消会话置顶 | +| `mute` | 会话消息免打扰开关 | +| `group-mute` | 群全员禁言开关 | +| `group-mute-member` | 群成员禁言开关 | +| `list-categories` | 列出用户自定义会话分类 | +| `list-conversations` | 按分类拉取会话列表 | + +### bot (机器人管理) + +| 子命令 | 用途 | +|-------|------| +| `bot search` | 搜索我创建的机器人 | + +--- + +## group create — 创建内部群 + +当前登录用户自动成为群主。 + +``` +Usage: + dws chat group create [flags] +Example: + dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 +Flags: + --users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填) + --name string 群名称 (必填) +``` + +--- + +## group members list — 查看群成员列表 + +分页查询指定群聊的成员。 + +``` +Usage: + dws chat group members list [flags] +Example: + dws chat group members list --id +Flags: + --cursor string 分页游标,首次从 0 开始 + --id string 群 ID / openconversation_id (必填) +``` + +> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。 + +--- + +## group members add — 添加群成员 + +向指定群聊添加成员,需传入群 ID 与用户 ID 列表。 + +``` +Usage: + dws chat group members add [flags] +Example: + dws chat group members add --id --users userId1,userId2 +Flags: + --id string 群 ID / openconversation_id (必填) + --users string 要添加的用户 userId 列表,逗号分隔 (必填) +``` + +--- + +## group members remove — 移除群成员 + +> ⚠️ 危险操作:执行前必须向用户确认,同意后才加 `--yes`。 + +``` +Usage: + dws chat group members remove [flags] +Example: + dws chat group members remove --id --users userId1,userId2 +Flags: + --id string 群 ID / openconversation_id (必填) + --users string 要移除的用户 userId 列表,逗号分隔 (必填) +``` + +--- + +## group members add-bot — 添加机器人到群 + +将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。 + +``` +Usage: + dws chat group members add-bot [flags] +Example: + dws chat group members add-bot --robot-code --id +Flags: + --id string 群聊 openConversationId (必填) + --robot-code string 机器人 Code (必填) +``` + +--- + +## group rename — 修改群名称 + +``` +Usage: + dws chat group rename [flags] +Example: + dws chat group rename --id --name "新群名" +Flags: + --id string 群 ID / openconversation_id (必填) + --name string 修改后的群名称 (必填) +``` + +--- + +## search — 搜索群会话 + +根据关键词搜索群聊会话,分页返回匹配的群列表。 + +``` +Usage: + dws chat search [flags] +Example: + dws chat search --query "项目冲刺" + dws chat search --query "项目冲刺" --limit 20 --cursor 0 +Flags: + --keyword string 搜索关键词 (必填,旧版别名: --query) + --limit int 每页返回数量(默认 20) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - 旧版命令使用 `--query`,新版统一改为 `--keyword`;`--query` 仍可作为别名使用 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## search-common — 搜索共同群 + +根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。 + +``` +Usage: + dws chat search-common [flags] +Example: + dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0 + dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0 + dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor +Flags: + --nicks string 要搜索的昵称列表,逗号分隔 (必填) + --match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND) + --limit int 每页返回数量(默认 20) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔" + - --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message send — 以当前用户身份发消息 + +--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。 +--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。 +--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。 + +``` +Usage: + dws chat message send [flags] [] +Example: + dws chat message send --group --title "周报" --text "请提交本周日报" + dws chat message send --user --title "提醒" --text "请查收" + dws chat message send --open-dingtalk-id --title "提醒" --text "请查收" + dws chat message send --group --title "通知" "hello" + dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" + dws chat message send --group --title "通知" --at-all "<@all> 请大家注意" + dws chat message send --group --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收" + dws chat message send --group --title "图片" --media-id + dws chat message send --open-dingtalk-id --title "图片" --media-id +Flags: + --text string 消息内容(推荐使用,也可用位置参数) + --group string 群聊 openconversation_id(群聊时必填) + --user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + --title string 消息标题(必填) + --at-all @所有人(仅群聊时生效,可选,默认 false) + --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选) + --at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选) + --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容 + --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断) + --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id) + --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用) + --file-name string 文件消息的文件名 + --file-size string 文件消息的文件大小(字节) + --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等) + +注意: + - --text 和位置参数二选一,--text 优先 + - --title 必填(群聊与单聊都必填,API 强制要求) + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2> + - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user + - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file +``` + +--- + +## message list — 拉取会话消息内容 + +拉取指定群聊或单聊的会话消息内容。 + +--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。 + +``` +Usage: + dws chat message list [flags] +Example: + # 拉取群聊中某个时间点之后的消息 + dws chat message list --group --time "2025-03-01 00:00:00" + # 拉取单聊消息(通过 userId) + dws chat message list-direct --user --time "2025-03-01 00:00:00" --limit 50 + # 拉取单聊消息(通过 openDingTalkId) + dws chat message list-direct --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 + # 拉取某个时间点之前的消息(向过去翻页) + dws chat message list --group --time "2025-03-01 00:00:00" --forward=false +Flags: + --forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true) + --group string 群聊 openconversation_id(群聊时必填) + --limit int 返回数量,不传则不限制 + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息) + --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) + +注意: + - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数 +``` + +### 分页翻页说明(重要) + +`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆: + +| 命令 | 翻页参数 | 翻页值来源 | 值格式 | +|------|---------|-----------|--------| +| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) | +| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) | + +**翻页步骤(message list):** + +1. **首次请求**:指定起始时间 + ```bash + dws chat message list --group --time "2025-03-01 00:00:00" --limit 50 --format json + ``` +2. **检查响应**:查看 `hasMore` 字段 + - `hasMore: false` → 没有更多消息,翻页结束 + - `hasMore: true` → 还有更多消息,继续下一步 +3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`) +4. **下一页请求**:将该 `createTime` 作为 `--time` 传入 + ```bash + dws chat message list --group --time "2025-03-01 14:30:00" --limit 50 --format json + ``` +5. 重复步骤 2-4 直到 `hasMore: false` + +> ⚠️ **常见错误**: +> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。 +> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。 + +--- + +## message list-all — 拉取指定时间范围内当前用户的所有会话消息 + +分页拉取当前登录用户在指定时间范围内的所有会话消息。 + +--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。 + +``` +Usage: + dws chat message list-all [flags] +Example: + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 + dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" +Flags: + --start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取) + +注意: + - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" + - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 + - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 + - 时间格式统一为 yyyy-MM-dd HH:mm:ss +``` + +--- + +## message list-topic-replies — 拉取群话题回复消息列表 + +查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。 + +``` +Usage: + dws chat message list-topic-replies [flags] +Example: + dws chat message list-topic-replies --group --topic-id + dws chat message list-topic-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20 +Flags: + --group string 群会话 openconversationId (必填) + --topic-id string 话题 ID,由 dws chat message list 返回 (必填) + --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选) + --limit int 返回数量(默认 50) + --forward true=从老往新,false=从新往老(默认 false) +``` + +--- + +## message list-by-sender — 拉取指定发送者的消息 + +搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。 + +``` +Usage: + dws chat message list-by-sender [flags] +Example: + dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) + --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一 + - 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识 + - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-mentions — 拉取 @我 的消息 + +搜索时间范围内 @我 的消息,可选指定群聊。 + +``` +Usage: + dws chat message list-mentions [flags] +Example: + dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 +Flags: + --group string 群聊 openconversation_id(可选,不传则查全部) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 50) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## message list-focused — 拉取特别关注人的消息 + +拉取当前用户特别关注人的消息。 + +``` +Usage: + dws chat message list-focused [flags] +Example: + dws chat message list-focused --limit 50 + dws chat message list-focused --limit 20 --cursor +Flags: + --limit int 每页返回数量(默认 50) + --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor +``` + +--- + +## message list-unread-conversations — 获取未读会话列表 + +获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。 + +``` +Usage: + dws chat message list-unread-conversations [flags] +Example: + dws chat message list-unread-conversations + dws chat message list-unread-conversations --count 20 +Flags: + --count int 返回未读会话条数(可选) +``` + +--- + +## message search — 按关键词搜索消息 + +在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。 + +``` +Usage: + dws chat message search [flags] +Example: + dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 + dws chat message search --keyword "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 +Flags: + --keyword string 搜索关键词 (必填) + --group string 群聊 openconversation_id(可选,不传则搜索所有会话) + --start string 开始时间,ISO-8601 格式 (必填) + --end string 结束时间,ISO-8601 格式 (必填) + --limit int 每页返回数量(默认 100) + --cursor string 分页游标(默认 "0",翻页传 nextCursor) + +注意: + - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## conversation-info — 获取会话基础信息 + +按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。 + +``` +Usage: + dws chat conversation-info [flags] +Example: + dws chat conversation-info --group + dws chat conversation-info --open-dingtalk-id +Flags: + --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一) + +注意: + - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一 +``` + +--- + +## list-top-conversations — 拉取置顶会话列表 + +拉取当前用户的置顶会话列表。 + +``` +Usage: + dws chat list-top-conversations [flags] +Example: + dws chat list-top-conversations --limit 1000 + dws chat list-top-conversations --limit 1000 --cursor +Flags: + --limit int 每页返回数量(默认 1000) + --cursor int 分页游标(首次不传或传 0,翻页传 nextCursor) + +注意: + - 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可 + - 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group ` 分别拉取每个会话内的消息 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor +``` + +--- + +## bot search — 搜索我的机器人 + +``` +Usage: + dws chat bot search [flags] +Example: + dws chat bot search --page 1 + dws chat bot search --page 1 --size 10 --name "日报" +Flags: + --name string 按名称搜索 + --page int 页码,从1开始 (默认 1) + --size int 每页条数 (默认 50),别名: --limit +``` + +--- + +## message send-by-bot — 机器人发消息 + +群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。 + +``` +Usage: + dws chat message send-by-bot [flags] +Example: + dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..." + dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" +Flags: + --group string 群聊 openConversationId(群聊时必填) + --robot-code string 机器人 Code (必填) + --text string 消息内容 Markdown (必填) + --title string 消息标题 (必填) + --users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填) + +注意: + - --group 与 --users 互斥,必须且只能指定其一 + - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) +``` + +--- + +## message recall-by-bot — 机器人撤回消息 + +群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。 + +``` +Usage: + dws chat message recall-by-bot [flags] +Example: + dws chat message recall-by-bot --robot-code --group --keys + dws chat message recall-by-bot --robot-code --keys key1,key2 +Flags: + --group string 群聊 openConversationId(群聊撤回时必填) + --keys string 消息 processQueryKey 列表,逗号分隔 (必填) + --robot-code string 机器人 Code (必填) +``` + +--- + +## message send-by-webhook — 自定义机器人 Webhook 发消息 + +@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。 + +``` +Usage: + dws chat message send-by-webhook [flags] +Example: + dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all + dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785 +Flags: + --at-all @ 所有人 + --at-mobiles string @ 指定手机号,逗号分隔 + --at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId) + --text string 消息内容 (必填) + --title string 消息标题 (必填) + --token string Webhook Token (必填) +``` + +--- + +--- + +## group get-by-group-id — 按数字群号获取会话信息 + +当用户只提供数字群号而非 openConversationId 时,用此命令把群号转换为 openConversationId,再调用其他需要 openConversationId 的命令。 + +``` +Usage: + dws chat group get-by-group-id [flags] +Example: + dws chat group get-by-group-id --group-id 12345678 +Flags: + --group-id string 群号 (必填,数字字符串) +``` + +--- + +## group transfer-owner — 转让群主 + +将群主身份转让给群内其他成员。--new-owner 传新群主的 openDingTalkId。 + +``` +Usage: + dws chat group transfer-owner [flags] +Example: + dws chat group transfer-owner --group --new-owner + # 查询群 ID: dws chat search --query "群名" + # 查询 openDingTalkId: dws contact user search --query "姓名" +Flags: + --group string 群聊 openConversationId (必填) + --new-owner string 新群主 openDingTalkId (必填) +``` + +--- + +## group invite-url — 获取群邀请链接 + +获取指定群聊的邀请加入链接。可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。 + +``` +Usage: + dws chat group invite-url [flags] +Example: + dws chat group invite-url --group + dws chat group invite-url --group --expires-seconds 86400 + dws chat group invite-url --group --expires-seconds 0 +Flags: + --group string 群聊 openConversationId (必填) + --expires-seconds int 链接有效期(秒),0=永久有效,不传使用服务端默认值 +``` + +--- + +## group quit — 退出群聊 + +当前用户退出指定群聊。 + +``` +Usage: + dws chat group quit [flags] +Example: + dws chat group quit --group +Flags: + --group string 群聊 openConversationId (必填) +``` + +--- + +## group update-icon — 更新群头像 + +更新指定群聊的群头像,--icon-media-id 传通过 media upload 获取的 mediaId。 + +``` +Usage: + dws chat group update-icon [flags] +Example: + dws chat group update-icon --group --icon-media-id +Flags: + --group string 群聊 openConversationId (必填) + --icon-media-id string 群头像 mediaId (必填,先用 media upload 上传获取) +``` + +--- + +## group update-settings — 更新群设置 + +--setting-key 指定设置项,--status 指定值(通常 "0"=关闭,"1"=开启;含义随 settingKey 变化)。 + +支持的 settingKey: `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`toolbarStatus`、`pluginCustomizeVerify`、`onlyAdminCanDING`、`allMembersCanCreateMcsConf`、`onlyAdminCanSetMsgTop`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`allMembersCanCreateCalendar`、`groupEmailDisabled`、`groupRedEnvelopeSwitch`、`groupLiveAuthority`、`groupBillAuthority` + +``` +Usage: + dws chat group update-settings [flags] +Example: + dws chat group update-settings --group --setting-key searchable --status 1 + dws chat group update-settings --group --setting-key onlyAdminCanAtAll --status 0 +Flags: + --group string 群聊 openConversationId (必填) + --setting-key string 群设置项 key (必填) + --status string 设置值: "0"=关闭, "1"=开启 (必填,字符串类型) +``` + +--- + +## group set-admin — 设置/取消群管理员 + +默认设为管理员,传 --off 取消。--users 传目标成员的 openDingTalkId 列表(逗号分隔)。 + +``` +Usage: + dws chat group set-admin [flags] +Example: + dws chat group set-admin --group --users openDingTalkId1,openDingTalkId2 + dws chat group set-admin --group --users openDingTalkId1 --off +Flags: + --group string 群聊 openConversationId (必填) + --users string 目标成员 openDingTalkId 列表 (必填,逗号分隔) + --off 传 --off 取消管理员(默认设为管理员) +``` + +--- + +## group member-role — 群自定义角色 + +`group member-role` 系列命令用于管理群的自定义身份/角色标签。包含 7 个子命令: + +- `list` — 列出群自定义角色 +- `add` — 新增群自定义角色 +- `update` — 更新群自定义角色名称 +- `remove` — 删除群自定义角色 +- `set-user` — 给成员设置自定义角色(覆盖语义,传空则清除该成员所有角色) +- `remove-user` — 移除成员的指定自定义角色(不影响其他角色) +- `query-user` — 查询成员当前持有的所有自定义角色 + +``` +Usage: + dws chat group member-role list --group + dws chat group member-role add --group --name "管理员" + dws chat group member-role update --group --role-id --name "新名称" + dws chat group member-role remove --group --role-id + dws chat group member-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group member-role remove-user --group --user --role-ids roleId1 + dws chat group member-role query-user --group --user + +注意: + - --user 传 openDingTalkId(不是 userId),可通过 `dws contact user search --query "姓名"` 获取 + - --role-id 来源于 `member-role list` 返回的 openRoleId + - `set-user` 是覆盖语义:传空 --role-ids 则清除该用户所有角色 + - `remove-user` 是增量语义:仅移除指定角色,不影响其他角色 +``` + +--- + +## message recall-by-bot — 撤回机器人发出的消息 + +> **能力范围说明**:开源 dws v1.0.30 仅提供 **机器人消息撤回**(`recall-by-bot`),不提供"撤回当前用户自己发出的消息"的命令。如果你想撤回的是普通用户消息,请在钉钉客户端长按消息撤回。 + +撤回机器人发出的消息,需要机器人 robotCode 和消息 processQueryKey。群聊撤回还需要 `--group `。 + +``` +Usage: + dws chat message recall-by-bot [flags] +Example: + # 群聊撤回机器人消息 + dws chat message recall-by-bot --robot-code --group --keys + # 单聊撤回机器人消息(不传 --group) + dws chat message recall-by-bot --robot-code --keys key1,key2 +Flags: + --robot-code string 机器人 Code (必填) + --group string 群会话 openConversationId (群聊撤回必填;单聊撤回不传) + --keys string 逗号分隔的消息 processQueryKey 列表 (必填) +``` + +--- + +## message reply — 引用回复消息 + +以当前用户身份引用某条消息并回复,**单聊与群聊均可**。需指定会话 ID、被引用消息 ID、被引用消息发送者 openDingTalkId、回复正文。 + +``` +Usage: + dws chat message reply [flags] +Example: + dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理" +Flags: + --conversation-id string 会话 openConversationId (必填,支持单聊/群聊) + --ref-msg-id string 被引用的消息 openMessageId (必填,由 message list 返回) + --ref-sender string 被引用消息发送者 openDingTalkId (必填) + --text string 回复正文 (必填) + --uuid string 幂等 UUID (可选) + +注意: + - --conversation-id 单聊和群聊使用同一字段;目前回复类型仅支持 text +``` + +--- + +## message forward — 转发消息 + +转发单条消息,**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单。 + +``` +Usage: + dws chat message forward [flags] +Example: + dws chat message forward --src-conversation-id --msg-id --dest-conversation-id +Flags: + --src-conversation-id string 源会话 openConversationId (必填) + --msg-id string 源消息 openMessageId (必填,由 message list 返回) + --dest-conversation-id string 目标会话 openConversationId (必填) +``` + +--- + +## message list-by-ids — 按消息 ID 批量获取消息 + +根据消息 ID 列表批量获取消息内容(最多 50 条)。 + +``` +Usage: + dws chat message list-by-ids [flags] +Example: + dws chat message list-by-ids --msg-ids msgId1,msgId2,msgId3 +Flags: + --msg-ids string 消息 openMessageId 列表 (必填,逗号分隔,最多 50 条) +``` + +--- + +## message query-send-status — 查询消息发送状态 + +查询通过 `message send` 等命令发送的消息的发送状态。需要传入发送时返回的 openTaskId。 + +``` +Usage: + dws chat message query-send-status [flags] +Example: + dws chat message query-send-status --open-task-id +Flags: + --open-task-id string 消息任务 ID openTaskId (必填,来自 send/send-by-bot 返回值) +``` + +--- + +## message query-read-status — 查询消息已读/未读状态 + +查询指定会话中消息的已读/未读状态(**仅消息发送者**可查询自己发出的消息)。 + +``` +Usage: + dws chat message query-read-status [flags] +Example: + dws chat message query-read-status --group --msg-id +Flags: + --group string 会话 openConversationId (必填,群聊或单聊均可) + --msg-id string 消息 openMessageId (必填,必须是当前用户发送的消息) + +注意: + - 仅消息发送者可查询自己发出的消息的已读/未读状态;查询他人发的消息会报错 + - --msg-id 从 `dws chat message list` 返回的消息列表中获取(字段名 openMessageId) +``` + +--- + +## message search-advanced — 多维度搜索消息(推荐首选) + +> 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(keyword 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索 @我 的消息)。仅在以下场景需要退回其他命令:按 userId(非 openDingTalkId)搜发送者时用 list-by-sender,拉取「特别关注人」消息时用 list-focused。 + +支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度组合搜索消息。所有参数均为可选,至少指定一个搜索条件。 + +``` +Usage: + dws chat message search-advanced [flags] +Example: + dws chat message search-advanced --keyword "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --sender-ids , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" + dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0 + dws chat message search-advanced --conversation-ids <单聊openConversationId> --keyword "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" +Flags: + --keyword string 搜索关键词(可选) + --sender-ids string 发送者 openDingTalkId 列表,逗号分隔(可选) + --at-me 只搜索 @我 的消息(可选,默认 false) + --at-ids string @指定人的 openDingTalkId 列表,逗号分隔(可选) + --conversation-ids string 会话 openConversationId 列表,逗号分隔(可选,群聊或单聊均可,不传则搜索所有会话) + --start string 开始时间,ISO-8601 格式(可选) + --end string 结束时间,ISO-8601 格式(可选) + --cursor string 分页游标(默认 "0") + --limit int 每页返回数量(默认 100) + +注意: + - 所有参数均为可选,但至少需要指定一个搜索条件 + - --sender-ids 和 --at-ids 传 openDingTalkId(非 userId),可通过 `dws contact user search --query "姓名"` 获取 + - 群聊 openConversationId 通过 `dws chat search --query "群名"` 获取;单聊 openConversationId 通过 `dws chat conversation-info --open-dingtalk-id ` 获取 + - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等 + - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor + - 替代关系:完全替代 search(严格超集);大部分替代 list-by-sender(按 openDingTalkId 搜索)和 list-mentions(--at-me 覆盖核心功能);不能替代 list-focused(「特别关注」是独立维度) +``` + +--- + +## 表情回应(emoji / 文字表情) + +钉钉消息支持两种"表情回应":内置 emoji 表情 + 自定义文字表情。优先用 emoji(参见 [chat-emoji-list.md](chat-emoji-list.md) 中的 199 个默认名称,如「赞」「鼓掌」「感谢」),命中即用 `add-emoji`;未命中先用 `create-text-emotion` 创建文字表情拿到 emotionId,再 `add-text-emotion` 贴上。 + +### message add-emoji — 给消息添加 emoji 表情回应 + +``` +Usage: + dws chat message add-emoji [flags] +Example: + dws chat message add-emoji --group --msg-id --emoji "赞" + dws chat message add-emoji --group --msg-id --emoji "鼓掌" +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emoji string emoji 表情名称 (必填,参见 chat-emoji-list.md 的 name 字段) +``` + +### message remove-emoji — 移除 emoji 表情回应 + +``` +Usage: + dws chat message remove-emoji [flags] +Example: + dws chat message remove-emoji --group --msg-id --emoji "赞" +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emoji string emoji 表情名称 (必填) +``` + +### message create-text-emotion — 创建文字表情(获取 emotionId) + +当 chat-emoji-list.md 中没有所需表情时,先创建再贴。 + +``` +Usage: + dws chat message create-text-emotion [flags] +Example: + dws chat message create-text-emotion --emotion-name "赞" --text "nice" + dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5 +Flags: + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID(可选,不传则由服务端默认分配) +``` + +### message add-text-emotion — 添加文字表情回应 + +需要先通过 `create-text-emotion` 创建拿到 emotionId 才能贴。六个参数全部必填。 + +``` +Usage: + dws chat message add-text-emotion [flags] +Example: + dws chat message add-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5 +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --msg-id string 消息 openMessageId (必填) + --emotion-id string 表情 ID (必填,通过 create-text-emotion 获取) + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID (必填) +``` + +### message remove-text-emotion — 移除文字表情回应 + +``` +Usage: + dws chat message remove-text-emotion [flags] +Example: + dws chat message remove-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id +Flags: + --group string 会话 openConversationId (必填) + --msg-id string 消息 openMessageId (必填) + --emotion-id string 表情 ID (必填) + --emotion-name string 表情名称 (必填) + --text string 文字内容 (必填) + --background-id string 背景 ID (必填) +``` + +--- + +## message send-card — 创建并推送流式卡片 + +向群聊或单聊发送流式卡片消息。群聊传 --group,单聊传 --user(openDingTalkId),二者互斥。 + +**⚠️ 必须配套使用 `update-card`:** 创建卡片后通过 `update-card` 推送内容更新,**最后一次更新必须将 `--flow-status` 设为 3(finish)**,否则卡片会一直停在"生成中"加载态。 + +flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。 + +``` +Usage: + dws chat message send-card [flags] +Example: + dws chat message send-card --group + dws chat message send-card --user + dws chat message send-card --group --card-template-id --card-data '{"key":"value"}' +Flags: + --group string 群聊 openConversationId(群聊时必填,与 --user 互斥) + --user string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥) + --card-template-id string 卡片模板 ID(可选) + --card-data string 卡片数据 JSON(可选) +``` + +## message update-card — 流式更新卡片内容 + +--biz-id 为 send-card 返回的业务 ID,--flow-status 控制流式状态。**最后一次更新必须将 --flow-status 设为 3(finish)**。 + +``` +Usage: + dws chat message update-card [flags] +Example: + dws chat message update-card --biz-id --content "更新的卡片内容" --flow-status 2 + dws chat message update-card --biz-id --content "最终内容" --flow-status 3 +Flags: + --biz-id string 卡片业务 ID (必填,来自 send-card 返回的 bizId) + --content string 卡片消息内容 (必填) + --flow-status int 流式状态 (必填,1/2/3/4/5) +``` + +--- + +## set-top — 设置/取消会话置顶 + +设置/取消会话置顶(**单聊/群聊均可**)。默认置顶,传 --off 取消置顶。 + +``` +Usage: + dws chat set-top [flags] +Example: + dws chat set-top --group + dws chat set-top --group --off +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --off 传 --off 取消置顶(默认置顶) +``` + +--- + +## mute — 会话消息免打扰 + +开启或关闭会话消息免打扰(**单聊/群聊均可**)。默认开启,传 --off 关闭免打扰。 + +``` +Usage: + dws chat mute [flags] +Example: + dws chat mute --group + dws chat mute --group --off +Flags: + --group string 会话 openConversationId (必填,支持单聊/群聊) + --off 传 --off 关闭免打扰(默认开启) +``` + +--- + +## group-mute — 群全员禁言 + +全员禁言/取消全员禁言。默认禁言,传 --off 取消。 + +``` +Usage: + dws chat group-mute [flags] +Example: + dws chat group-mute --group + dws chat group-mute --group --off +Flags: + --group string 群聊 openConversationId (必填) + --off 传 --off 取消全员禁言(默认开启) +``` + +--- + +## group-mute-member — 群成员禁言 + +指定群成员禁言/解除禁言。默认禁言,传 --off 解除。 + +--mute-time 为禁言时长(毫秒),仅禁言时必填,常用值:300000(5 分钟)、3600000(1 小时)、86400000(1 天)、604800000(1 周)、2592000000(30 天)。 + +``` +Usage: + dws chat group-mute-member [flags] +Example: + dws chat group-mute-member --group --users openDingTalkId1,openDingTalkId2 --mute-time 3600000 + dws chat group-mute-member --group --users openDingTalkId1 --off +Flags: + --group string 群聊 openConversationId (必填) + --users string 被禁言成员 openDingTalkId 列表 (必填,逗号分隔) + --mute-time int 禁言时长(毫秒,仅禁言时必填) + --off 传 --off 解除禁言(默认禁言) +``` + +--- + +## list-categories — 列出用户自定义会话分类 + +获取当前用户的所有自定义会话分类。 + +``` +Usage: + dws chat list-categories +Example: + dws chat list-categories +``` + +## list-conversations — 按分类拉取会话列表 + +根据分类 ID 拉取该分类下的会话列表。分类 ID 通过 `list-categories` 获取。 + +``` +Usage: + dws chat list-conversations [flags] +Example: + dws chat list-conversations --category-id +Flags: + --category-id string 分类 ID (必填,由 list-categories 返回) +``` + + +## 意图判断 + +用户说"建群/创建群聊" → `chat group create` +用户说"搜索群/找群" → `chat search` +用户说"群成员/看群里有谁" → `chat group members list` +用户说"拉人进群/加群成员" → `chat group members add` +用户说"踢人/移除群成员" → `chat group members remove` +用户说"加机器人到群" → `chat group members add-bot` +用户说"改群名" → `chat group rename` +用户说"聊天记录/会话消息/拉取会话" → `chat message list` +用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊) +用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(用户明确说"单聊"时使用) +用户说"@我的消息/at我的/提及我的" → `chat message list-mentions` +用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations` +用户说"发群消息(以个人身份)" → `chat message send --group` +用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时) +用户说"机器人发消息/机器人群发" → `chat message send-by-bot` +用户说"机器人撤回消息" → `chat message recall-by-bot` +用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook` +用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies` +用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all` +用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused` +用户说"查看我的机器人" → `chat bot search` +用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search` +用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common` +用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations` +用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info` +用户说"撤回我发的消息/撤回消息" → `chat message recall-by-bot`(仅能撤回机器人消息;撤回用户自己发的消息开源 dws v1.0.30 暂不支持,需在钉钉客户端长按撤回) +用户说"撤回机器人发的消息/机器人撤回消息" → `chat message recall-by-bot`(机器人接口撤回机器人发出的消息,需要 robot-code + processQueryKey) +用户说"引用回复/回复消息/引用消息回复" → `chat message reply` +用户说"转发消息/转发一条消息/把消息转发到另一个群" → `chat message forward` +用户说"消息已读未读/谁看了消息/查读状态/消息读取状态" → `chat message query-read-status` +用户说"查询消息发送状态/消息发没发成功/消息状态" → `chat message query-send-status` +用户说"多维度搜索消息/按发送者搜消息/按人搜消息/指定多个群搜索/@我的消息搜索" → `chat message search-advanced`(推荐首选,支持多维度组合搜索) +用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids` +用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji` +用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji` +用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion` +用户说"取消文字表情回应/移除文字表情" → `chat message remove-text-emotion` +用户说"创建文字表情/新建文字表情" → `chat message create-text-emotion` +用户说"流式卡片/AI 卡片/发送卡片" → `chat message send-card` → `chat message update-card`(必须配合 update-card,最后一次 --flow-status 必须为 3 finish) +用户说"置顶会话/取消置顶/会话置顶(操作)" → `chat set-top`(设置/取消置顶),`chat list-top-conversations`(查看置顶列表) +用户说"免打扰/消息免打扰/静音/开启免打扰/关闭免打扰" → `chat mute` +用户说"全员禁言/群禁言/解除禁言" → `chat group-mute` +用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member` +用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin` +用户说"转让群主/换群主/群主转让" → `chat group transfer-owner` +用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url` +用户说"退出群聊/我要退群" → `chat group quit` +用户说"改群头像/更新群头像" → `chat group update-icon` +用户说"群设置/修改群设置" → `chat group update-settings` +用户说"根据群号查群信息/群号查群/群号转 openConversationId" → `chat group get-by-group-id`(用户提供数字群号时把它转为 openConversationId) +用户说"查看群身份/群的自定义身份列表/群成员角色" → `chat group member-role list` +用户说"创建/添加群身份/添加群角色" → `chat group member-role add` +用户说"修改群身份名称/改群角色名" → `chat group member-role update` +用户说"删除群身份/删群角色" → `chat group member-role remove` +用户说"给某人设置群身份/设定用户角色" → `chat group member-role set-user` +用户说"移除某人的群身份/撤销群角色" → `chat group member-role remove-user` +用户说"查询某人的群身份/某人在群里有什么身份" → `chat group member-role query-user` +用户说"查看会话分类/自定义分类" → `chat list-categories` +用户说"某个分类下的会话/分类会话列表" → `chat list-conversations` + +关键区分: +- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页 +- `chat message list-direct --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用) +- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用) +- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群) +- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`) +- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令 +- `chat message list-topic-replies` — 拉取群话题的回复消息列表 +- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页 +- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页 +- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息 +- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群) +- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在) +- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag +- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息 +- `chat message recall-by-bot` — 通过机器人撤回已发送的消息 +- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等) +- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot +- `chat message recall-by-bot` — 撤回机器人发出的消息(需 --robot-code + --keys;群聊另加 --group)。撤回用户自己的消息开源 dws v1.0.30 不支持 +- `chat message reply` — 引用回复消息(单聊/群聊均可),以当前用户身份,需 --conversation-id + --ref-msg-id + --ref-sender + --text +- `chat message forward` — 转发单条消息(源/目标会话均支持单聊/群聊),需 --src-conversation-id + --msg-id + --dest-conversation-id +- `chat message list-by-ids` — 按消息 ID 列表批量查询(最多 50 条) +- `chat message query-send-status` — 查询消息发送状态,需 --open-task-id(send 返回值) +- `chat message query-read-status` — 查询消息的已读/未读状态(仅消息发送者可查询自己发的消息),需 --group + --msg-id +- `chat message search-advanced` — 多维度搜索消息(推荐首选,是 search 的严格超集,可叠加 keyword/sender-ids/at-me/at-ids/conversation-ids/start/end) +- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应(参考 chat-emoji-list.md 的 199 个默认表情) +- `chat message add-text-emotion` / `remove-text-emotion` — 添加/移除文字表情回应 +- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用 +- `chat message send-card` / `update-card` — 流式卡片,必须配套使用;最后一次 update-card 的 --flow-status 必须为 3 (finish) +- `chat set-top` — 设置/取消会话置顶(单聊/群聊均可),默认置顶,--off 取消 +- `chat mute` — 开启/关闭会话消息免打扰(单聊/群聊均可),默认开启,--off 关闭 +- `chat group-mute` — 群全员禁言开关,默认开启,--off 关闭 +- `chat group-mute-member` — 指定群成员禁言,需 --users + --mute-time(毫秒,仅禁言时必填),--off 解除 +- `chat group set-admin` — 设置/取消群管理员,默认设为管理员,--off 取消 +- `chat group transfer-owner` — 转让群主 +- `chat group invite-url` — 获取群邀请链接,可选 --expires-seconds(0=永久) +- `chat group quit` — 当前用户退出群聊 +- `chat group update-icon` / `update-settings` — 更新群头像 / 群设置 +- `chat group get-by-group-id` — 把数字群号转为 openConversationId +- `chat group member-role` — 群自定义角色管理(list/add/update/remove/set-user/remove-user/query-user) +- `chat list-categories` / `list-conversations` — 自定义会话分类列表 / 按分类拉会话 + +## 核心工作流 + +```bash +# 1. 搜索群 — 提取 openconversation_id +dws chat search --query "项目冲刺" --format json + +# 2. 拉取群消息 +dws chat message list --group --time "2025-03-01 00:00:00" --format json + +# 2b. 拉取未读会话列表 +dws chat message list-unread-conversations --count 20 --format json + +# 3. 以个人身份发送群消息 +dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" --format json + +# 4. 以个人身份单聊(通过 userId) +dws chat message send --user --title "问候" "你好" --format json + +# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用) +dws chat message send --open-dingtalk-id --title "问候" "你好" --format json + +# 5. 机器人发群消息(Markdown) +dws chat message send-by-bot --robot-code \ + --group --title "日报" --text "## 今日完成..." --format json + +# 6. 机器人单聊发消息 +dws chat message send-by-bot --robot-code \ + --users userId1,userId2 --title "提醒" --text "请提交周报" --format json + +# 7. Webhook 发告警 +dws chat message send-by-webhook --token \ + --title "告警" --text "CPU 超 90%" --at-all --format json +``` + +## 复合工作流 + +### 机器人发消息后撤回(完整流程) + +撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。 + +```bash +# Step 1: 查我的机器人 — 提取 robot-code +dws chat bot search --format json + +# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey +dws chat message send-by-bot --robot-code --group \ + --title "通知" --text "内容" --format json + +# Step 3: 用同一个 robot-code + processQueryKey 撤回 +dws chat message recall-by-bot --robot-code --group \ + --keys --format json +``` + +### 将已有机器人加入群并发消息(完整流程) + +机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。 + +```bash +# Step 1: 搜索我的机器人 — 提取 robotCode +dws chat bot search --name "项目提醒" --format json + +# Step 2: 搜索群 — 提取 openConversationId +dws chat search --query "项目群" --format json + +# Step 3: 将机器人添加到群(需当前用户对该群有管理权限) +dws chat group members add-bot --id --robot-code --format json + +# Step 4: 机器人发消息 +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "请及时更新项目状态" --format json +``` + +### 机器人 @指定人发群消息 + +`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。 + +```bash +# Step 1: 搜人获取 userId +dws aisearch person --keyword "张三" --dimension name --format json + +# Step 2: 取 userId 发送(注意 text 中的占位符) +dws chat message send-by-bot --robot-code --group \ + --title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json +``` + +### 发送图片/文件消息(跨产品: drive → chat) + +```bash +# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证 +dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json + +# Step 2: HTTP PUT 上传文件到 OSS +curl -X PUT -T "截图.png" "" + +# Step 3: 提交上传 — 获取 dentryUuid +dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id --format json + +# Step 4: 获取下载链接 +dws drive download --file-id --format json + +# Step 5: 用 Markdown 图片语法发送 +dws chat message send --group \ + --title "截图" --text "![截图](下载链接)" --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `chat search` | `openConversationId` | message send/list、group members 等的 --group | +| `chat group create` | `openConversationId` | 同上 | +| `chat group get-by-group-id` | `openConversationId` | 把数字群号转换后给 message send/list 等命令使用 | +| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor | +| `chat message search` / `search-advanced` | `nextCursor` | 下次 message search/search-advanced 的 --cursor | +| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id | +| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id、search-advanced 的 --sender-ids/--at-ids、reply 的 --ref-sender、group transfer-owner 的 --new-owner、group set-admin / group-mute-member 的 --users、group member-role 系列的 --user | +| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code | +| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | +| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id | +| `chat message list` | `openMessageId` | recall 的 --msg-id、reply 的 --ref-msg-id、forward 的 --msg-id、query-read-status 的 --msg-id、list-by-ids 的 --msg-ids、add-emoji/add-text-emotion 的 --msg-id | +| `chat conversation-info` | `openConversationId` / `newCSpaceIdIM` | 作为 message send/list 等后续操作的会话上下文;newCSpaceIdIM 用于 drive upload-info 的 --space-id | +| `chat search-common` | `openConversationId` | message send/list 等的 --group | +| `chat list-categories` | `categoryId` | list-conversations 的 --category-id | +| `chat group member-role list` | `openRoleId` | group member-role update/remove/set-user/remove-user 的 --role-id / --role-ids | +| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | +| `chat message send-card` | `bizId` | update-card 的 --biz-id | +| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 | + +## 注意事项 + +- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取 +- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id` +- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor +- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一 +- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识 +- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部 +- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数 +- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话 +- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑 +- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor` +- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一 +- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) +- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效 + +- **发消息前参数审查(必须执行)**:发消息(`message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`)是严肃操作,发错人/发错群会导致严重问题。执行前 agent 必须把全部参数(收件人/群、消息内容、@对象、消息类型等)与用户的原始需求逐一对比,确认每个参数都能从原始需求中找到明确依据;存在任何不明确/有歧义/原始需求中未提及的参数,必须先向用户确认,严禁自行假设 +- **消息换行符**:`message send` / `send-by-bot` / `send-by-webhook` 的 `--text` 按 Markdown 渲染,换行有两层要求,缺一不可:(1) 必须使用**真实换行符** `U+000A`,而非字面量字符串 `\n`;(2) Markdown 单换行不产生换行,需用空行(连续两个真实换行符)做段落分隔,或行尾两空格 + 换行 / `
` 做硬换行 +- `chat message recall` 撤回当前用户自己发的消息,--group + --msg-id 必填;与 `recall-by-bot`(撤回机器人消息,--robot-code + --keys)区分 +- `chat message reply` 引用回复(单聊/群聊均可),需 --conversation-id + --ref-msg-id + --ref-sender + --text;目前回复类型仅支持 text +- `chat message forward` 转发消息(源/目标会话单聊群聊均可),需 --src-conversation-id + --msg-id + --dest-conversation-id +- `chat message query-send-status` 查询消息发送状态,需 --open-task-id(send 返回值) +- `chat message query-read-status` 查询消息已读/未读状态,仅消息发送者可查询自己发的消息,需 --group + --msg-id +- `chat message search-advanced` 多维度搜索(推荐首选),所有参数可选但至少传一个;--sender-ids / --at-ids 传 openDingTalkId +- `chat message list-by-ids` --msg-ids 逗号分隔,最多 50 条 +- `chat message add-emoji` / `remove-emoji` 需 --group + --msg-id + --emoji;--emoji 取自 chat-emoji-list.md 的 name 列 +- `chat message add-text-emotion` / `remove-text-emotion` 六个参数全部必填:--group + --msg-id + --emotion-id + --emotion-name + --text + --background-id +- `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选 +- `chat message send-card` 创建并推送流式卡片,群聊传 --group,单聊传 --user(openDingTalkId);**必须配合 update-card 使用,最后一次 update-card 的 --flow-status 必须为 3(finish)**,否则卡片会一直显示"生成中" +- `chat set-top` 设置/取消会话置顶(单聊/群聊均可),需 --group,默认置顶,--off 取消 +- `chat mute` 开启/关闭会话消息免打扰(单聊/群聊均可),需 --group,默认开启,--off 关闭 +- `chat group-mute` 群全员禁言开关,需 --group,默认开启,--off 关闭 +- `chat group-mute-member` 指定成员禁言,需 --group + --users(openDingTalkId 逗号分隔)+ --mute-time(毫秒,仅禁言时必填,常用 300000/3600000/86400000/604800000/2592000000),--off 解除 +- `chat group set-admin` 设置/取消群管理员,需 --group + --users(openDingTalkId 逗号分隔),默认设为管理员,--off 取消 +- `chat group transfer-owner` 转让群主,需 --group + --new-owner(openDingTalkId) +- `chat group invite-url` 获取群邀请链接,需 --group,可选 --expires-seconds(0=永久有效) +- `chat group quit` 退出群聊,需 --group +- `chat group update-icon` 更新群头像,需 --group + --icon-media-id(mediaId) +- `chat group update-settings` 更新群设置,需 --group + --setting-key + --status(字符串类型 "0"/"1") +- `chat group get-by-group-id` 把数字群号转换为 openConversationId,需 --group-id(数字字符串) +- `chat group member-role` 系列管理群自定义角色:list 查列表,add 创建,update 改名,remove 删除;set-user 覆盖某人全部角色(传空 --role-ids 则清除),remove-user 仅移除指定角色,query-user 查询某人当前角色;--user 传 openDingTalkId(非 userId) +- `chat list-categories` 列出当前用户自定义会话分类(无参数) +- `chat list-conversations` 按分类拉取会话列表,需 --category-id(字符串,由 list-categories 返回) +- **如何获取 openConversationId**(上层已有则直接用,不必重复查):群聊 `dws chat search --query "群名"`;单聊 `dws chat conversation-info --open-dingtalk-id ` +- **如何获取 openDingTalkId**:知道姓名 → `dws contact user search --query "姓名"`;只有 userId → 先 `dws contact user get --ids ` 拿姓名,再 search + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [chat_export_messages.py](../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | +| [chat_history_with_user.py](../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | +| [extract_media_id.py](../scripts/extract_media_id.py) | 从 dt_media_upload URL 提取 mediaId | `python extract_media_id.py ""`(输出如 @lQLPxxx,直接用于 --media-id) | + +## 相关产品 + +- `dingtalk-contact` (`references/contact.md`) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id、search-advanced 的 --sender-ids/--at-ids、reply 的 --ref-sender、group transfer-owner 的 --new-owner、group set-admin / group-mute-member 的 --users、group member-role 系列的 --user +- `dingtalk-drive` (`references/drive.md`) — 上传文件获取下载链接,用于 Markdown 图片/文件消息 diff --git a/skills/multi/dingtalk-chat/scripts/bot_broadcast.py b/skills/multi/dingtalk-chat/scripts/bot_broadcast.py new file mode 100644 index 00000000..5317e222 --- /dev/null +++ b/skills/multi/dingtalk-chat/scripts/bot_broadcast.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +用机器人向多个群批量发送相同消息(如日报提醒) + +用法: + python bot_broadcast.py \ + --robot-code \ + --chats "conv_id1,conv_id2,conv_id3" \ + --title "日报提醒" \ + --text "请大家今天下班前提交日报" + + python bot_broadcast.py \ + --robot-code \ + --chats-file groups.txt \ + --title "周会通知" \ + --text "明天下午3点周会" + + python bot_broadcast.py --dry-run ... +""" + +import sys +import json +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def main(): + parser = argparse.ArgumentParser( + description='向多个群批量发送机器人消息' + ) + parser.add_argument( + '--robot-code', required=True, help='机器人 Code' + ) + parser.add_argument('--chats', default='', help='会话 ID 列表') + parser.add_argument( + '--chats-file', default='', + help='会话 ID 文件 (每行一个)', + ) + parser.add_argument('--title', required=True, help='消息标题') + parser.add_argument( + '--text', required=True, help='消息内容 Markdown' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + chat_ids: List[str] = [] + if args.chats: + chat_ids = [c.strip() for c in args.chats.split(',') + if c.strip()] + elif args.chats_file: + p = Path(args.chats_file) + if not p.exists(): + print(f"错误:文件不存在: {p}") + sys.exit(1) + chat_ids = [line.strip() for line in + p.read_text(encoding='utf-8').splitlines() + if line.strip() and not line.startswith('#')] + if not chat_ids: + print('错误:需要 --chats 或 --chats-file') + sys.exit(1) + + print(f"📢 批量发送消息到 {len(chat_ids)} 个群") + print(f" 标题: {args.title}") + print(f" 机器人: {args.robot_code}") + print('=' * 50) + + success, fail = 0, 0 + for i, chat_id in enumerate(chat_ids, 1): + result = run_dws([ + 'chat', 'message', 'send-by-bot', + '--robot-code', args.robot_code, + '--group', chat_id, + '--title', args.title, + '--text', args.text, + '--format', 'json', + ], dry_run=args.dry_run) + if result: + print(f" ✓ [{i}/{len(chat_ids)}] {chat_id}") + success += 1 + else: + print(f" ✗ [{i}/{len(chat_ids)}] {chat_id}") + fail += 1 + + print(f"\n完成: 成功 {success}, 失败 {fail}") + sys.exit(0 if fail == 0 else 1) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-chat/scripts/chat_export_messages.py b/skills/multi/dingtalk-chat/scripts/chat_export_messages.py new file mode 100644 index 00000000..54324607 --- /dev/null +++ b/skills/multi/dingtalk-chat/scripts/chat_export_messages.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +导出群聊消息到 JSON 文件(从指定时间点拉取) + +用法: + python chat_export_messages.py \ + --group \ + --time "2026-03-10 00:00:00" \ + --output messages.json + + python chat_export_messages.py \ + --query "项目冲刺" \ + --time "2026-03-10 00:00:00" \ + --no-forward --limit 100 +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def search_group( + query: str, dry_run: bool = False, +) -> Optional[str]: + data = run_dws([ + 'chat', 'search', + '--query', query, '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + if isinstance(data, list): + groups = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + groups = inner.get('items', inner.get('groups', [])) + elif isinstance(inner, list): + groups = inner + else: + groups = [] + else: + groups = [] + if not groups: + print(f"未找到群聊: {query}") + return None + g = groups[0] + name = g.get('title') or g.get('name', '未知') + conv_id = g.get('openConversationId') or g.get('id') + print(f" 找到群聊: {name} ({conv_id})") + return conv_id + + +def main(): + parser = argparse.ArgumentParser( + description='导出群聊消息到 JSON' + ) + parser.add_argument('--group', help='群聊 openconversation_id') + parser.add_argument('--query', help='按群名搜索') + parser.add_argument( + '--time', required=True, + help='起始时间 yyyy-MM-dd HH:mm:ss', + ) + parser.add_argument( + '--no-forward', action='store_true', + help='拉给定时间之前的消息 (默认拉给定时间之后)', + ) + parser.add_argument( + '--limit', type=int, default=0, + help='返回条数 (不传则不限制)', + ) + parser.add_argument('--output', default='', help='输出文件') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + conv_id = args.group + if not conv_id: + if not args.query: + print('错误:需要 --group 或 --query 参数') + sys.exit(1) + print(f'🔍 搜索群聊: {args.query}') + conv_id = search_group(args.query, args.dry_run) + if not conv_id and not args.dry_run: + sys.exit(1) + + print(f'📥 拉取消息 (起始: {args.time})...') + all_messages: List[Any] = [] + current_time = args.time + page = 0 + max_pages = 50 + remaining = args.limit if args.limit > 0 else float('inf') + + while page < max_pages and remaining > 0: + cmd_args = [ + 'chat', 'message', 'list', + '--group', conv_id or '', + '--time', current_time, + '--format', 'json', + ] + if args.no_forward: + cmd_args.append('--forward=false') + page_limit = min(int(remaining), 200) if args.limit > 0 else 0 + if page_limit > 0: + cmd_args.extend(['--limit', str(page_limit)]) + data = run_dws(cmd_args, dry_run=args.dry_run) + + if args.dry_run: + print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') + return + + if not data: + break + + if isinstance(data, list): + page_msgs = data + has_more = False + else: + page_msgs = data.get('messages', data.get('result', [])) + has_more = data.get('hasMore', False) + + if not page_msgs: + break + + all_messages.extend(page_msgs) + remaining -= len(page_msgs) + page += 1 + + if not has_more: + break + + last_msg = page_msgs[-1] + boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + if not boundary_time or boundary_time == current_time: + break + current_time = boundary_time + print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") + + if not all_messages: + print('未拉取到消息') + return + + if isinstance(data, list): + messages = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + messages = inner.get('messages', inner.get('records', [])) + elif isinstance(inner, list): + messages = inner + else: + messages = [] + else: + messages = [] + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(all_messages, f, ensure_ascii=False, indent=2) + print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") + else: + for m in all_messages: + sender = m.get('senderNick') or m.get('sender', '未知') + text = m.get('text') or m.get('content', '') + time_str = m.get('createAt') or m.get('time', '') + print(f" [{time_str}] {sender}: {text[:80]}") + print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py new file mode 100644 index 00000000..8068be38 --- /dev/null +++ b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +查询与某人的单聊聊天记录 + +用法: + python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00" + python chat_history_with_user.py --user --time "2026-03-10 00:00:00" --limit 50 + python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json + +工作流: + 1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user) + 2. 调用 chat message list --user 拉取单聊消息 + 3. 输出到终端或导出为 JSON 文件 +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + """执行 dws 命令并解析 JSON 输出""" + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def search_user( + name: str, dry_run: bool = False, +) -> Optional[str]: + """按关键词搜索用户,返回第一个匹配的 userId""" + data = run_dws([ + 'contact', 'user', 'search', + '--query', name, '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + # 解析返回结构 + users = data + if isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + users = (inner.get('users', []) + or inner.get('list', [])) + elif isinstance(inner, list): + users = inner + else: + users = [] + if not users or not isinstance(users, list): + print(f"未找到用户: {name}") + return None + u = users[0] + user_name = u.get('name') or u.get('nick', '未知') + user_id = u.get('userId') or u.get('userid', '') + print(f" 找到用户: {user_name} ({user_id})") + return user_id + + +def main(): + parser = argparse.ArgumentParser( + description='查询与某人的单聊聊天记录' + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--name', help='按姓名搜索用户') + group.add_argument('--user', help='直接指定 userId') + parser.add_argument( + '--time', required=True, + help='起始时间 yyyy-MM-dd HH:mm:ss', + ) + parser.add_argument( + '--no-forward', action='store_true', + help='拉给定时间之前的消息 (默认拉给定时间之后)', + ) + parser.add_argument( + '--limit', type=int, default=0, + help='返回条数 (不传则不限制)', + ) + parser.add_argument('--output', default='', help='导出到 JSON 文件') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + # 1. 获取 userId + user_id = args.user + if not user_id: + print(f'🔍 搜索用户: {args.name}') + user_id = search_user(args.name, args.dry_run) + if not user_id and not args.dry_run: + sys.exit(1) + + # 2. 拉取单聊消息(自动翻页) + print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...') + all_messages: List[Any] = [] + current_time = args.time + page = 0 + max_pages = 50 + remaining = args.limit if args.limit > 0 else float('inf') + + while page < max_pages and remaining > 0: + cmd_args = [ + 'chat', 'message', 'list', + '--user', user_id or '', + '--time', current_time, + '--format', 'json', + ] + if args.no_forward: + cmd_args.append('--forward=false') + page_limit = min(int(remaining), 200) if args.limit > 0 else 0 + if page_limit > 0: + cmd_args.extend(['--limit', str(page_limit)]) + data = run_dws(cmd_args, dry_run=args.dry_run) + + if args.dry_run: + print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') + return + + if not data: + break + + if isinstance(data, list): + page_msgs = data + has_more = False + else: + page_msgs = data.get('messages', data.get('result', [])) + has_more = data.get('hasMore', False) + + if not page_msgs: + break + + all_messages.extend(page_msgs) + remaining -= len(page_msgs) + page += 1 + + if not has_more: + break + + last_msg = page_msgs[-1] + boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + if not boundary_time or boundary_time == current_time: + break + current_time = boundary_time + print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") + + if not all_messages: + print('未拉取到消息') + return + + # 3. 输出结果 + if isinstance(data, list): + messages = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + messages = inner.get('messages', inner.get('records', [])) + elif isinstance(inner, list): + messages = inner + else: + messages = [] + else: + messages = [] + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(all_messages, f, ensure_ascii=False, indent=2) + print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") + else: + for m in all_messages: + sender = m.get('senderNick') or m.get('sender', '未知') + text = m.get('text') or m.get('content', '') + time_str = m.get('createAt') or m.get('time', '') + print(f" [{time_str}] {sender}: {text[:80]}") + print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-chat/scripts/extract_media_id.py b/skills/multi/dingtalk-chat/scripts/extract_media_id.py new file mode 100644 index 00000000..7e2a3c23 --- /dev/null +++ b/skills/multi/dingtalk-chat/scripts/extract_media_id.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +从 dt_media_upload 返回的 URL 中提取 mediaId。 + +用法: + python extract_media_id.py + python extract_media_id.py "https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw_960_1280.png" + # 输出: @lQLPD4JNnliqBq3NBQDNA8Cw + +依赖: 无(纯 Python 标准库) + +典型工作流(发送图片/语音/视频消息): + # 1. 用 dt_media_upload 上传文件,获得 URL + # 2. 用本脚本从 URL 提取 mediaId + python extract_media_id.py "" + # 输出: @lQLPxxx(直接用于 --media-id 或 --pic-url 参数) +""" + +import argparse +import re +import sys +from urllib.parse import urlparse + + +def extract_media_id(url: str) -> str: + """从 dt_media_upload 返回的 URL 中提取 mediaId。 + + 支持的 URL 格式示例: + https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw_960_1280.png + https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw.png + https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw + + 提取规则: + 1. 取 URL 路径中 /media/ 之后的部分 + 2. 去除尾部的 _数字_数字.扩展名 后缀(如 _960_1280.png) + 3. 如果没有尺寸后缀,去除尾部的 .扩展名 + 4. 加上 @ 前缀 + + 返回: @lQLPD4JNnliqBq3NBQDNA8Cw + """ + url = url.strip() + + # 提取路径部分 + parsed = urlparse(url) + path = parsed.path # e.g. /media/lQLPxxx_960_1280.png + + # 取 /media/ 之后的部分 + media_prefix = "/media/" + idx = path.find(media_prefix) + if idx == -1: + print(f"错误:URL 中未找到 /media/ 路径: {url}", file=sys.stderr) + sys.exit(1) + raw = path[idx + len(media_prefix):] # e.g. lQLPxxx_960_1280.png + + if not raw: + print(f"错误:/media/ 后无内容: {url}", file=sys.stderr) + sys.exit(1) + + # 尝试去除 _数字_数字.扩展名 后缀(如 _960_1280.png) + match = re.match(r'^(.+?)(_\d+_\d+\.\w+)$', raw) + if match: + media_id = match.group(1) + else: + # 没有尺寸后缀,尝试去除 .扩展名 + match2 = re.match(r'^(.+)\.\w+$', raw) + if match2: + media_id = match2.group(1) + else: + # 无后缀,直接使用 + media_id = raw + + return f"@{media_id}" + + +def main(): + parser = argparse.ArgumentParser( + description="从 dt_media_upload 返回的 URL 中提取 mediaId" + ) + parser.add_argument("url", help="dt_media_upload 返回的文件 URL") + args = parser.parse_args() + + media_id = extract_media_id(args.url) + print(media_id) + + +if __name__ == "__main__": + main() diff --git a/skills/multi/dingtalk-contact/SKILL.md b/skills/multi/dingtalk-contact/SKILL.md new file mode 100644 index 00000000..682be0ff --- /dev/null +++ b/skills/multi/dingtalk-contact/SKILL.md @@ -0,0 +1,48 @@ +--- +name: dingtalk-contact +description: 钉钉通讯录精确查询(按 userId 查详情、部门搜索、部门成员列表、查自己信息)。Use when 用户说 查部门/部门成员/我的信息/按工号查/按 userId 查/orgAuthEmail。Distinct from dingtalk-aisearch(模糊搜人首选:找同事/查上下级/谁负责)。命令前缀:dws contact。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉通讯录 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[contact.md](references/contact.md);剧本:[08-directory.md](references/08-directory.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查我自己的信息" | `dws contact user get-self` | +| "按 userId 查详情" | `dws contact user get --ids ,,...`(多个并行) | +| "按部门名拉成员" | `python scripts/contact_dept_members.py --query "<部门名>"` | +| "搜部门" | `dws contact dept search --query "<关键词>"` | +| "部门成员列表" | `dws contact dept list-members --ids ` | + +## 评测高频硬约束 + +- 通讯录问题必须调用 `dws contact` 或 `dws aisearch` 获取实时结果;严禁只读 `USER.md`、环境身份或静态上下文后直接回答。 +- 查自己用 `dws contact user get-self --format json`,不要把 `me/self/current` 当作 `userId` 传给 `user get`。 +- 精确找人、按工号、按手机号:先用 `dws aisearch person --keyword "<完整输入>" --dimension name/jobNumber/phone --format json` 或对应 `contact user search/search-mobile`;拿到 `userId` 后必须 `dws contact user get --ids --format json` 补部门/职位/邮箱。 +- 查询直属主管/上下级时,如果 `contact user get` 没返回明确主管字段,必须继续 `dws aisearch person --keyword "<完整姓名或工号>" --dimension supervisor --format json`,不要停在"可能需要进一步查询"。 +- 多个同名候选时,批量 `contact user get --ids id1,id2,... --format json` 获取部门/职位后再消歧;不要默认取第一个。 + +## 跨产品协作 + +- 模糊找人(姓名 / 上下级 / 谁负责 / 工号 / 手机号)→ 切到 `dingtalk-aisearch` +- 拿到 email 发邮件 → 切到 `dingtalk-mail` +- 拿到 userId 发消息 → 切到 `dingtalk-chat` diff --git a/skills/multi/dingtalk-contact/references/08-directory.md b/skills/multi/dingtalk-contact/references/08-directory.md new file mode 100644 index 00000000..dc96bcf2 --- /dev/null +++ b/skills/multi/dingtalk-contact/references/08-directory.md @@ -0,0 +1,50 @@ +# 通讯录(组织架构) + +> **SKILL.md** 中 #8 仅内联 2 条 **lite**:`get-contact-self`、`search-user`。下列 recipe、专用规则与消歧请在命中 #8 且**超出**上述 lite 时阅读本文。 +> 产品命令见 [contact.md](./contact.md)。通用批量/并行见 `dws-shared/references/best_practices/_common/conventions.md`。 + +## 专用规则(#8 非 lite 步骤必守) + +- **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id `;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names ` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。 +- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --ids`。 +- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids `;禁止仅用 search 的浅表字段交差。 +- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。 +- **多命中**:`user search` 或 `dept search` 多条时须列候选(姓名、title、部门线索)请用户确认,禁止默认猜一人。具体消歧流程: + 1. 从搜索结果中提取所有同名/多命中用户的 `userId` + 2. 调用 `contact user get --ids userId1,userId2,...` 获取每人详情(含 `depts` 部门列表、职位等) + 3. 将「姓名 + 部门 + 职位」列表展示给用户,请用户确认选择哪一位 + 4. 使用用户确认的 `userId` 继续后续操作 + > **根因**:`user search` 和 `aisearch person` 均不返回部门信息,无法仅凭搜索结果区分同名用户。**必须**追加 `contact user get` 获取部门信息才能消歧。 +- **批量**:多个 `userId` 用 `contact user get --ids id1,id2,...`;多部门成员列表按需并行,遵守单次批量上限与 `dws-shared/references/best_practices/_common/conventions.md`。 +- **子部门枚举**:已知父部门 `deptId` 时**优先** `contact dept list-children --id <父deptId>` 直接拿到完整子部门列表;只知道部门名时先 `contact dept search --query "<父部门名>"` 取 `deptId` 再 `list-children`;用户明示的子部门名(无需枚举)可直接 `dept search` 命中。多子部门展开见 `explore-subdepts-and-members`。 + +## 与其他场景消歧 + +- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `contact label list` + `label list-members`;label 精确命中角色维度,返回完整名单;aisearch 是语义模糊搜索不保证完整性。 +- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `dingtalk-aisearch/references/aisearch.md`。 +- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。 +- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。 +- **终点是发消息、待办、日程** → 先用 `search-person` 或 `search-user` 取 `userId`,再进入 #1 / #2 / #3。 +- **联系客户 + 发邮件** → 先用 `contact` 取 `orgAuthEmail`,再走 `dingtalk-mail/references/mail.md`。 + +## Recipe 速查(本表步骤,非 SKILL lite) + +| Recipe | 步骤 | +|--------|------| +| `lookup-label-members` | 1. `contact label list` → 浏览全部角色,匹配目标角色的 labelId
2. `contact label list-members --id ` → 该角色下的成员列表 | +| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids ` | +| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) | +| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --id <父deptId>` 直接取直属子部门列表
2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` | +| `list-dept-members` | 1. **优先** `python scripts/contact_dept_members.py --query "<部门名>"`
2. 备选:`lookup-dept-id` → `contact dept list-members --ids `
3. 若要每人档案字段:对 `userId` 批量 `contact user get --ids …` | +| `list-multi-dept-members` | 1. 对每个部门名 `contact dept search --query "<名>"` → 各 `deptId`
2. `contact dept list-members --ids ,,...`(多部门并行/批量见 conventions)
3. 需要档案再 `contact user get --ids …` | +| `verify-user-dept` | 1. `contact dept search --query "<部门名>"` → `deptId`
2. `contact dept list-members --ids ` 中匹配姓名;或先 `search-user` lite 再 `user get` 核对部门字段 | + +## Full / 多步组合 + +| Recipe | 行动指南(固定路线) | +|--------|---------------------| +| explore-subdepts-and-members | 1. 取父部门 `deptId`:用户给了 ID 直接用;只给名字则 `contact dept search --query "<父部门名>"` → 父 `deptId`(多命中先消歧)
2. **优先** `contact dept list-children --id <父deptId>` 拿到全部直属子 `deptId` 列表;若用户只点名了部分子部门,则改为对每个子部门名 `contact dept search --query "<子部门名>"`
3. 对子 `deptId`:`contact dept list-members --ids ,,...`(多部门按 conventions **并行/批量**)
4. 若还要成员详情:汇总 `userId` → `contact user get --ids …`(≤30 条/批,超出分批 + 用户确认) | +| verify-user-in-dept | 同速查表 `verify-user-dept`;多轮对话中用户追加「是否在某部门」时叠加本路线 | +| cross-level-dept-members | 1. `contact dept search --query "<父部门关键词>"` → 父 `deptId`
2. **优先** `contact dept list-children --id <父deptId>` 枚举全部直属子 `deptId`;用户已点名的子部门则用 `dept search` 精确命中
3. 需要逐层下钻时,对上一步拿到的子 `deptId` 继续 `dept list-children` 递归(注意控制深度,避免一次拉太多)
4. `contact dept list-members --ids ` → 按需 `user get` | +| user-detail-organization | 1. `contact user search --query "<关键词>"` → `userId`(多结果先消歧)
2. **必须** `contact user get --ids `
3. 若用户同时给出部门语境:叠加 `verify-user-dept` | +| batch-users-by-keyword | 1. `contact user search --query "<职位或技能关键词>"` → 多条
2. 提取 `userId`(≤30)→ `contact user get --ids …`
3. 结果过多时汇总或请用户收窄 | diff --git a/skills/multi/dingtalk-contact/references/contact.md b/skills/multi/dingtalk-contact/references/contact.md new file mode 100644 index 00000000..9c06903f --- /dev/null +++ b/skills/multi/dingtalk-contact/references/contact.md @@ -0,0 +1,276 @@ +# 通讯录 (contact) 命令参考 + +> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `label` / `relation` 四个二级子命令。 +> 不存在 `contact search`、`contact find`、`contact list`、`contact get`、`contact user find/list`。 +> 构造命令前必须确认路径在下方「命令总览」中存在;不确定时,**根据意图对照下方「意图判断」选择正确命令**。 +> +> **CRITICAL — 根部门**:钉钉根部门 `deptId=1`。`dept` 系列命令查根部门统一传 `--id 1` 或 `--ids 1`,不要传 `self / me / root / 0`。 + + +> ⚠️ **CLI 暴露状态(开源 dws v1.0.30)**:本文档列出的 `contact label list` / `label get` / `label list-members` / `relation` 等子命令在开源 dws v1.0.30 未暴露。开源 `dws contact` 仅暴露 3 个子命令:`dept` / `search` / `user`。角色/label 相关查询请改用: +> - 角色成员 → `dws aisearch person --keyword "<角色名>" --dimension label` +> - 部门成员 → `dws contact dept list-members --id ` + +## 命令总览 + +### user (人员查询) + +#### 获取当前用户信息 +``` +Usage: + dws contact user get-self [flags] +Aliases: + get-self, self, me, whoami, current +Example: + dws contact user get-self + dws contact user self # 别名 + dws contact user me # 别名 + dws contact user whoami # 别名 + dws contact user current # 别名 +Notes: + - 触发词:我是谁 / 我的信息 / 我的 userId / 当前用户 / 本人 / self / me / whoami + - 顶层亦已挂 `dws contact get-self / user-self / current-user` 提示,误写会引导到正确命令 + - **禁止**用 `dws contact user get --ids me/self/current` 代替(会报错);正确用法是 `get-self` 或其别名 +``` + +#### 按关键词搜索用户 +``` +Usage: + dws contact user search [flags] +Example: + dws contact user search --query "张三" +Flags: + --query string 搜索关键词 (必填) +Returns: (列表,每项包含以下字段) + name string 成员姓名 + nick string 成员昵称 + userId string 成员 ID(仅同事关系时返回) + title string 员工职位(仅同事关系时返回) + openDingTalkId string 当前用户视角下的目标用户唯一标识,不可跨用户共享;可用于发消息等好友关系场景的操作 +``` + +> **CAUTION:** 多人同名时禁止默认选第一个 — `user search` 不返回部门信息,须追加 `contact user get --ids userId1,userId2,...` 获取部门/职位后请用户确认。详见 [08-directory.md](./08-directory.md)「多命中」。 + +#### 按手机号搜索用户 +``` +Usage: + dws contact user search-mobile [flags] +Example: + dws contact user search-mobile --mobile 13800138000 +Flags: + --mobile string 手机号 (必填) +``` + +#### 批量获取用户详情 +``` +Usage: + dws contact user get [flags] +Example: + dws contact user get --ids userId1,userId2 +Flags: + --ids string 用户 ID 列表,逗号分隔 (必填) +Notes: + - **禁止**将 `self/me/current/whoami` 作为 userId 传入;查自己请用 `dws contact user get-self` +``` + +### dept (部门查询) + +#### 搜索部门 +``` +Usage: + dws contact dept search [flags] +Example: + dws contact dept search --query "技术部" +Flags: + --query string 搜索关键词 (必填) +``` + +#### 获取部门详情 +``` +Usage: + dws contact dept get-info [flags] +Example: + dws contact dept get-info --id 12345 +Flags: + --id string 部门 ID (必填) +Notes: + - **钉钉根部门 `deptId=1`**;查根部门用 `--id 1` +``` + +#### 查看子部门 +``` +Usage: + dws contact dept list-children [flags] +Example: + dws contact dept list-children --id 1 # 枚举根部门下的一级部门 + dws contact dept list-children --id 12345 # 枚举指定部门的直属子部门 +Flags: + --id string 父部门 ID (必填) +Returns: + success bool 调用是否成功 + result list 直属子部门列表,每项包含以下字段: + deptId int 子部门 ID + deptName string 子部门名称 +Notes: + - **钉钉根部门 `deptId=1`**;查询一级部门请用 `--id 1` + - 仅返回**直属**(直接下一级)子部门,不递归;需要逐层下钻请对子 deptId 继续调用本命令 + - 受组织架构可见性控制:仅返回调用者**有权限查看**的子部门 + - 父部门不可见或无子部门时返回 result=[] 空列表(非错误) +``` + +#### 查看部门成员 +``` +Usage: + dws contact dept list-members [flags] +Example: + dws contact dept list-members --ids 12345,67890 + dws contact dept list-members --ids 1 # 根部门 +Flags: + --ids string 部门 ID 列表,逗号分隔 (必填) +Notes: + - **钉钉根部门 `deptId=1`**;查根部门直属成员用 `--ids 1` + - 仅返回**本部门**直接成员,**不含下级部门**成员;需含下级请先 `dept list-children` 枚举子部门,再对子 deptId 分别/合并调用 `list-members` + - 受组织架构可见性控制;`--ids` 支持逗号分隔批量查询多个部门 + - 跨层级成员展开见 [08-directory.md](./08-directory.md) 的 `cross-level-dept-members` recipe +``` + +## 意图判断 + +> **搜人首选 `aisearch person`**:凡是“找人/搜人/找同事/谁负责/上级/下级”均优先用 `aisearch person`,详见 `dingtalk-aisearch/references/aisearch.md`,以下场景才用 contact。 + +用户说"我是谁/我的信息/我的 userId/当前用户/本人/self/me/whoami" → `user get-self`(无需参数;禁止用 `user get --ids me/self` 代替) +用户需要 userId 给其他产品使用(发消息/建待办/约日程)→ `user search`(按名字)或 `user search-mobile`(按手机号) +用户说"查用户详情/部门/主管/管理员" → `user get`(需 userId,返回组织管理信息) +用户说"花名册字段/有哪些字段/字段列表" → `user profile fields` +用户说"花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同" → `user profile get`(需 staffId,返回个人档案信息) +用户说"离职员工/离职名单/离职人员/已离职" → `user dismission search` +用户说"找部门/哪个部门" → `dept search` +用户说"部门详情/部门信息/部门多少人" → `dept get-info`(返回部门ID、部门名称、部门人数;需 deptId,若只有部门名称需先 `dept search`) +用户说"子部门/下设部门/部门有哪些下级部门/枚举二级部门" → `dept list-children`(需父 deptId;只有部门名先 `dept search`) +用户说"部门有谁/部门成员/人员名单" → `dept list-members`(需 deptId;**仅本部门不含下级**,含下级先 `dept list-children` 再合并查) +用户查询涵盖"角色"(主管/管理员/财务/HR/总经理等任意角色名)→ 统一走 `contact label` 链路,按下方决策树选命令: +- 不知道角色名 / 枚举所有角色 → `label list` +- 已知角色名,查ID或成员 → 先 `label get --names <名>` 拿ID,查成员再调 `label list-members --id `;**精确匹配无结果时降级 `label list` 模糊匹配** +- 已知角色ID 查成员 → `label list-members --id ` + +> [!IMPORTANT] +> **角色查询 3 步决策树**(不依赖字串匹配,按语义判定): +> 1. 不知道角色名 / 要枚举所有角色(列出企业有哪些角色、每个角色的名称和ID、不确定叫什么角色、负责XX的人有哪些等) → `label list` +> 2. 已知角色名 要查角色ID → `label get --names` +> 3. 已知角色ID 要查成员 → `label list-members --id` +> +> 任何含"角色"一词的查询默认走 `contact label` 链路。**唯一例外**:终点是"某个人是否具备某权限"(如"张三是不是管理员")→ `user get`。禁止路由到 `user profile fields`(那是花名册字段)、`dept list-members` 筛选、或 OA/chat 模块。 + +> [!IMPORTANT] +> **角色查人 vs 查某人的角色信息 — 判断口径**:先判断用户的终点是"人"还是"属性": +> - 终点是**人**("管理员角色有哪些人""管理员下都有谁""查XX角色的成员")→ 角色维度查人,**必须**走 `contact label` 链路(`label get`/`label list` → `label list-members`),**禁止**通过 `dept list-members` 筛选 `isAdmin` 等字段替代 +> - 终点是**属性**("张三是不是管理员""查某人的主管/管理员权限")→ 已知 userId 查个人详情,走 `user get`(返回 isAdmin/leader 等字段) +> +> 反例对照: +> - "管理员角色下都有哪些人" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**) +> - "我想知道管理员这个角色下都有谁" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**) +> - "张三是不是管理员" → `user get --ids `(终点=某个人的**属性**) +> - "查一下张三的管理员权限" → `user get --ids `(终点=某个人的**属性**) +> - "角色ID为55808858的角色下有哪些成员" → `label list-members --id 55808858`(已有角色ID直接查成员) +> +> **角色 = 通讯录 label**:用户提到"角色"(角色ID/角色成员/角色名称/企业角色/查角色下的人)时,均指通讯录组织角色,应走 `contact label` 链路。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。 +用户说"我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" → `relation list-my-followings` + +> [!IMPORTANT] +> **易混淆硬规则**:`relation list-my-followings` **只**返回"我特别关注的人员列表"(一组 openDingTalkId),**不**返回任何消息内容。 +> +> **禁止路由到本命令的场景**(query 中同时包含『关注/特别关注/星标』和以下任一消息域动词/名词时,必须路由到 `chat message list-focused`,详见 `dingtalk-chat/references/chat.md`): +> - 动词类:**发**了什么、**说**了什么/啥、**聊**了什么、**讲**了什么 +> - 名词类:**消息**、**聊天**、**动态**、**最新内容** +> +> **判断口径**:先扫描 query 是否含上述动词/名词;含则路由到 `chat message list-focused`,**不论** query 主语是否为"我特别关注的人"。 +> +> 反例对照: +> - "我特别关注的人有哪些" → `relation list-my-followings`(终点=人员列表) +> - "我特别关注的人**最近发了什么消息**" → `chat message list-focused`(含"发""消息") +> - "我关注的人**最近都说了啥**" → `chat message list-focused`(含"说") + +组合场景(多子部门、跨层级成员、强消歧)见 [08-directory.md](./08-directory.md)。 + +## 核心工作流 + +```bash +# 1. 查看自己的信息 — 提取 userId +dws contact user get-self --format json + +# 2. 按名字搜索同事或好友 — 可提取 同事的userId,或好友的openDingTalkId +dws contact user search --query "张三" --format json + +# 3. 查看部门结构 — 提取 deptId +dws contact dept search --query "技术部" --format json + +# 4. 查看部门详情(部门ID、名称、人数) +dws contact dept get-info --id --format json + +# 5. 查看直属子部门 — 提取子 deptId 列表 +dws contact dept list-children --id <父deptId> --format json + +# 6. 查看部门成员 +dws contact dept list-members --ids --format json + +# 7. 获取企业所有角色列表 — 不知道角色名时先浏览 +dws contact label list --format json + +# 8. 根据角色名称查询角色 +dws contact label get --names "管理员" --format json + +# 9. 查询角色下的成员 +dws contact label list-members --id --format json + +# 10. 查询花名册有权限的字段列表 +dws contact user profile fields --format json + +# 11. 根据字段 code 查询指定员工的花名册信息 +dws contact user profile get --staff-id --fields fieldCode1,fieldCode2 --format json + +# 12. 查询所有可见字段的花名册信息 +dws contact user profile get --staff-id --format json + +# 13. 查询全部离职员工 +dws contact user dismission search --format json + +# 14. 按姓名/时间范围/部门筛选离职员工 +dws contact user dismission search --name "张三" --format json +dws contact user dismission search --start 2026-01-01 --end 2026-03-31 --format json +dws contact user dismission search --depts 123456,789012 --hide-retirement=false --format json +``` + +## 上下文传递表 + +| 操作 | 提取 | 用于 | +|------|------|------| +| `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 | +| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) | +| `user get-self/search` | `userId` | profile get 的 --staff-id | +| `user profile fields` | `fieldCode` | profile get 的 --fields | +| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` | +| `label get` | `labelId` | `label list-members` 的 --id | +| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --id/--ids | +| `dept search/list-children` | `deptId` | dismission search 的 --depts | + +## 注意事项 + +- `user get-self` 是获取 userId 的最快方式,其他产品的 --users/--executor 都需要 userId +- `user get --ids` 和 `dept list-members --ids` 都支持批量查询,逗号分隔 +- `user get` 返回组织管理信息(部门、主管、管理员权限),`user profile get` 返回个人档案信息(学历、家庭、银行卡等),注意区分 +- `user profile get` 的 `--staff-id` 可通过 `user get-self`、`user search` 或 `aisearch person` 获取 +- `user profile get` 的 `--fields` 可通过 `user profile fields` 获取可用字段 code 列表;不填则查询所有可见字段 +- 建议先执行 `user profile fields` 获取可用字段列表,再根据需要的字段 code 执行 `user profile get` +- `user dismission search` 的 `--start`/`--end` 必须同时设置或同时不设置,不允许只传其中一个 +- `user dismission search` 默认隐藏退休人员(`--hide-retirement` 默认 true),默认展示合作伙伴(`--hide-partner` 默认 false) +- `label list` 无需参数,适用于不知道准确角色名称的场景;当用户说“查所有主管/主管理员/财务”等角色类型人员时,优先 `label list` 列出企业全部角色,LLM 灵活匹配目标角色后调用 `label list-members` +- 角色类查询(主管、管理员、财务、HR 等任意角色)优先走 label 链路,而非 dept list-members 或 aisearch person;label 精确命中角色维度,返回完整名单 +- `label get` 是精确匹配角色名称,不支持模糊搜索;支持逗号分隔同时查询多个角色名称 +- **`label get` 精确匹配无结果时的降级策略**:若 `label get --names "XX"` 返回空结果,必须降级调用 `label list` 获取全部角色列表,从中模糊匹配包含XX关键词的角色(如用户说"管理员"可匹配到"主管理员"和"子管理员"),再对匹配到的角色调用 `label list-members` +- `label list-members` 需要先通过 `label list` 或 `label get` 获取 labelId,再用 --id 查询角色下的成员 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [contact_dept_members.py](../scripts/contact_dept_members.py) | 按部门名称搜索并列出所有成员 | `python contact_dept_members.py --query "技术部"` | diff --git a/skills/multi/dingtalk-contact/scripts/contact_dept_members.py b/skills/multi/dingtalk-contact/scripts/contact_dept_members.py new file mode 100644 index 00000000..519afacf --- /dev/null +++ b/skills/multi/dingtalk-contact/scripts/contact_dept_members.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +按部门名称搜索并列出所有成员(自动 deptId 解析) + +用法: + python contact_dept_members.py --query "技术部" + python contact_dept_members.py --query "产品" --dry-run +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description='按部门名称搜索并列出所有成员' + ) + parser.add_argument( + '--query', required=True, help='部门名称关键词' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print(f'🔍 搜索部门: {args.query}') + dept_data = run_dws([ + 'contact', 'dept', 'search', + '--query', args.query, '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + run_dws([ + 'contact', 'dept', 'list-members', + '--ids', '', '--format', 'json', + ], dry_run=True) + return + + if not dept_data: + print('未找到匹配部门') + sys.exit(1) + + if isinstance(dept_data, list): + depts = dept_data + elif isinstance(dept_data, dict): + inner = dept_data.get('result', dept_data) + if isinstance(inner, dict): + depts = inner.get('items', inner.get('depts', [])) + elif isinstance(inner, list): + depts = inner + else: + depts = [] + else: + depts = [] + if not depts: + print('未找到匹配部门') + sys.exit(1) + + for dept in depts: + dept_id = dept.get('id') or dept.get('deptId') + dept_name = dept.get('name') or dept.get('deptName', '未知') + if not dept_id: + continue + + print(f"\n📂 {dept_name} (ID: {dept_id})") + print('-' * 40) + + members_data = run_dws([ + 'contact', 'dept', 'list-members', + '--ids', str(dept_id), '--format', 'json', + ]) + if not members_data: + print(' 无法获取成员列表') + continue + + if isinstance(members_data, list): + members = members_data + elif isinstance(members_data, dict): + inner = members_data.get('result', members_data) + if isinstance(inner, dict): + members = inner.get('userlist', + inner.get('list', [])) + elif isinstance(inner, list): + members = inner + else: + members = [] + else: + members = [] + if not members: + print(' (暂无成员)') + continue + + for m in members: + name = m.get('name') or m.get('userName', '未知') + title = m.get('title') or m.get('position', '') + uid = m.get('userId') or m.get('userid', '') + line = f" 👤 {name}" + if title: + line += f" ({title})" + if uid: + line += f" [ID: {uid}]" + print(line) + + print(f" 共 {len(members)} 人") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-devdoc/SKILL.md b/skills/multi/dingtalk-devdoc/SKILL.md new file mode 100644 index 00000000..2fb09b03 --- /dev/null +++ b/skills/multi/dingtalk-devdoc/SKILL.md @@ -0,0 +1,36 @@ +--- +name: dingtalk-devdoc +description: 钉钉开放平台开发文档搜索。Use when 用户说 开放平台文档/API 文档/接口文档/调用报错/开放接口怎么调。Distinct from dingtalk-doc(钉钉云文档)。命令前缀:dws devdoc。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉开放平台文档 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[devdoc.md](references/devdoc.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查 OAuth2 接入文档" | `dws devdoc article search --query "OAuth2 接入"` | +| "API 调用报错怎么办" | `dws devdoc article search --query "<报错关键词>"` | +| "开放接口文档" | `dws devdoc article search --query "<接口名或场景>"` | + +## 跨产品协作 + +- 钉钉云文档(个人 / 企业内文档)→ 切到 `dingtalk-doc` diff --git a/skills/multi/dingtalk-devdoc/references/devdoc.md b/skills/multi/dingtalk-devdoc/references/devdoc.md new file mode 100644 index 00000000..a542b62c --- /dev/null +++ b/skills/multi/dingtalk-devdoc/references/devdoc.md @@ -0,0 +1,23 @@ +# devdoc — 开放平台文档 + +## 搜索开放平台文档 +``` +Usage: + dws devdoc article search [flags] +Example: + dws devdoc article search --query "OAuth2 接入" --page 1 --size 10 --format json +Flags: + --query string 搜索关键词 (必填) + --page string 页码 (默认 1) + --size string 每页数量 (默认 10) +``` + +## 意图判断 + +- 用户说"开发文档 / API 文档 / 接口文档 / 调用报错" → `devdoc article search` + +## 上下文传递 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `devdoc article search` | 文档链接 | 直接展示给用户 | diff --git a/skills/multi/dingtalk-ding/SKILL.md b/skills/multi/dingtalk-ding/SKILL.md new file mode 100644 index 00000000..f19ab271 --- /dev/null +++ b/skills/multi/dingtalk-ding/SKILL.md @@ -0,0 +1,38 @@ +--- +name: dingtalk-ding +description: DING 紧急消息(应用内 / 短信 / 电话)。Use when 用户说 DING一下/紧急通知/电话DING/短信DING/必达消息/电话叫人。Distinct from dingtalk-chat(普通群聊消息)、dingtalk-outbound-call(企业外呼)。命令前缀:dws ding。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 DING 紧急消息 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[ding.md](references/ding.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "DING 张三" / "应用内紧急通知" | `dws ding message send --type app --users --content "<内容>"` | +| "短信 DING" | `dws ding message send --type sms --users --content "<内容>"` | +| "电话 DING" / "电话叫人" | `dws ding message send --type call --users --content "<内容>"` | +| "撤回 DING" | `dws ding message recall --id ` | + +## 跨产品协作 + +- 接收人是人名 → 先用 `dingtalk-aisearch` 拿 `userId` +- 普通通知(不需必达)→ 切到 `dingtalk-chat` diff --git a/skills/multi/dingtalk-ding/references/ding.md b/skills/multi/dingtalk-ding/references/ding.md new file mode 100644 index 00000000..1d813f7c --- /dev/null +++ b/skills/multi/dingtalk-ding/references/ding.md @@ -0,0 +1,63 @@ +# DING 消息 (ding) 命令参考 + +开源 `dws ding message` 仅支持 `send` 和 `recall` 两个子命令。 + +## 命令总览 + +### 发送 DING 消息 +``` +Usage: + dws ding message send [flags] +Example: + dws ding message send --robot-code --users , --content "请查看" +Flags: + --content string 消息内容 (必填) + --robot-code string 机器人 ID (必填, 可从 应用管理→机器人 获取, 或设 DINGTALK_DING_ROBOT_CODE) + --users string 接收人 userId 列表 (必填) + --type string 提醒类型: app/sms/call (默认 app) +``` + +### 撤回 DING 消息 +``` +Usage: + dws ding message recall [flags] +Example: + dws ding message recall --robot-code --id +Flags: + --id string DING 消息 openDingId (必填) + --robot-code string 机器人 ID (必填, 或设 DINGTALK_DING_ROBOT_CODE) +``` + +## 意图判断 + +用户说"DING 一下/紧急通知/电话提醒" → `message send` +用户说"撤回 DING" → `message recall` +用户说"DING 历史/接收状态/谁收到了 DING" → 开源版不支持,直接告知 + +关键区分: +- ding(紧急提醒, 支持电话/短信) vs bot(常规群/单聊消息) +- sms/call 类型有通信费用 + +## 核心工作流 + +```bash +# 应用内 DING (免费) +dws ding message send --robot-code --type app --users userId1,userId2 --content "请查看" --format json + +# 电话 DING (紧急, 有成本!) +dws ding message send --robot-code --type call --users userId1 --content "紧急告警" --format json + +# 撤回 +dws ding message recall --robot-code --id --format json +``` + +## 上下文传递表 +| 操作 | 提取 | 用于 | +|------|------|------| +| `message send` | `openDingId` | message recall 的 `--id` | + +## 注意事项 +- `--robot-code` 从钉钉开放平台 **应用管理 → 机器人** 中获取,也可设环境变量 `DINGTALK_DING_ROBOT_CODE` +- sms/call 类型有通信费用,使用前需和用户确认 +- 默认 `--type app`(应用内 DING,免费) +- 开源版不支持查询 DING 历史 / 接收状态,遇到这类需求直接告知 diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md new file mode 100644 index 00000000..54b1ad2e --- /dev/null +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -0,0 +1,69 @@ +--- +name: dingtalk-doc +description: 钉钉文档(云文档)。Use when 用户说 写文档/读文档/创建文档/编辑文档/搜文档/文档块/分块编辑/Markdown 写入/上传文件到文档。Distinct from dingtalk-drive(钉盘文件存储)、dingtalk-aitable(数据表格)、dingtalk-wiki(知识库空间)。命令前缀:dws doc。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉文档 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[doc.md](references/doc.md);剧本:[04-document.md](references/04-document.md);URL 识别与类型探测:[url-patterns.md](references/url-patterns.md)。 + +## URL 预检(含 alidocs URL 时必读) + +输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式:`/i/p/...`(分享短链)、`/i/nodes/...`(节点链接,类型需探测)、`/spreadsheetv2/...`(电子表格直链)、`/document/edit|preview?dentryKey=...`(文档链接)等,每种处理流程不同。**必须先读 [url-patterns.md](references/url-patterns.md) 中的「alidocs URL 分流决策」**,按规则识别 URL 类型后再选择对应命令;其中 `/document/edit|preview?dentryKey=...` 直接路由到 `doc`,将完整 URL 原样传给 `--node`,**不要**提取 `dentryKey` 当裸 nodeId。 + +## 参数硬约束 + +- 创建文档只用 `--name`,不要写 `--title`。 +- 目标文件夹只用 `--folder <文档文件夹nodeId或URL>`,不要写 `--parent` / `--parent-node` / `--parent-id`。 +- 目标知识库只用 `--workspace `,不要写 `--space-id` / `--spaceId`。 +- 文档内容:`create` 只接 `--markdown`,不要写 `--content` / `--content-file`;`update` 只接 `--content` / `--content-file`,不要写 `--markdown`。 +- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。 +- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "创建文档(短内容)" | `dws doc create --name "<标题>" --markdown "<内容>"` | +| "创建+写入(长内容自动分块)" | `python scripts/doc_create_and_write.py --name "<标题>" --content "<内容>" [--mode append\|overwrite]` | +| "搜文档" | `dws doc search --query "<关键词>"` | +| "读文档内容" | `dws doc read --node ` | +| "更新文档内容 / 分块追加" | `dws doc update --node --content "<分块>" --mode append` | +| "删除块" | `dws doc block delete`(需用户确认) | + +## 评测/多步文档短路径 + +- 知识库「评测记录」下按日期文件夹执行:`dws wiki space search --keyword "评测记录" --format json` → `dws doc list --workspace --format json` → 找 `评测-doc-YYYYMMDD`;不存在则 `dws doc folder create --name "评测-doc-YYYYMMDD" --workspace --format json`。 +- 在目标文件夹创建文字文档:`dws doc create --name "<标题>" --folder --markdown "$(cat )" --format json`。拿到 `nodeId` 后立即回读。 +- 块级编辑固定顺序:`doc block list --node ` → 选 `blockId` → `doc block insert/update/delete` → `doc block list` 验证。删除块必须已有用户明确删除意图或二次确认。 +- 插入引用块、代码块、表格、分栏、附件、图片时,优先读 [doc.md](references/doc.md) 对应小节,不要只停在"准备查看 help"。说出"我将插入..."后必须立即执行对应 terminal 调用。 +- 用户要求多个子文档/附件/块操作时,按 checklist 串行完成;最后一条 assistant 消息不能停在"接下来我要...",必须有实际工具调用或明确失败原因。 +- 用户说"读取并下载/导出"时,统一用 `doc download --node ... --output `(开源 v1.0.30 无 `doc export` 命令)。 +- 所有 dws 命令带 `--format json`;仅参数不确定时查 `--help`,不要把完整 help 当成最终结果。 + +## 危险操作 + +`block delete` 不可逆,必须确认再加 `--yes`。 + +## 跨产品协作 + +- 文件存储 / 上传下载 → 切到 `dingtalk-drive` +- 知识库空间管理 → 切到 `dingtalk-wiki` +- 数据表 → 切到 `dingtalk-aitable` +- 长篇报告生成(多源采集 + 写文档)→ 此 skill 提供 `doc_create_and_write.py` 脚本 diff --git a/skills/multi/dingtalk-doc/references/04-document.md b/skills/multi/dingtalk-doc/references/04-document.md new file mode 100644 index 00000000..a1721c2f --- /dev/null +++ b/skills/multi/dingtalk-doc/references/04-document.md @@ -0,0 +1,71 @@ +# 文档知识 + +> lite recipe 见 `dws-shared/references/best_practices/_common/lite-recipes.md`。 + +| Recipe | 行动指南(固定路线) | +|--------|-------------------| +| write-doc | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行
2. **先把内容写入临时文件**(Linux/Mac `/tmp/.md`,Windows `%TEMP%\.md`)—— 含多行/表格/长文本必须走文件,不要把 markdown 直接作为命令行字符串
3. **单步创建**(< 200KB):`doc create --name "<文档名>" --content-file [--folder ] [--workspace ]`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId)
4. **超长兜底**(> 200KB):**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" [--folder/--workspace]` → `nodeId` → 按段落切 ≤200KB 片段(不断表格) → 每片 `doc update --node --content-file --mode append`
5. **回读校验**(必须):所有写入完成后,执行 `doc read --node ` 回读文档,校验关键标题/段落是否完整写入(详见下方「doc update 回读校验规范」)
备选(仅短内容 <2KB 且无换行/表格):`doc create --name "..." --content "..."` | +| search-docs-and-share | 1. `doc search --query "<关键词>"` → 取 `nodeId` + 标题建索引(不读全文)
2. `doc read --node `(追问按需,最多 2 篇) | +| create-knowledge-base | 1. 创建知识库空间取 `WS_ID`
2. `doc create --name "<文档名>" --workspace ` → 取 `nodeId`
3. `doc list --workspace ` 确认 | +| migrate-doc | 1. `doc read --node <源nodeId>` → 取正文并写入临时文件 `.md`
2. `doc create --name "<文档名>" --folder --content-file .md` → 取新 `nodeId`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId;正文 <200KB 单步到位)
2a. 若正文 >200KB:**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" --folder ` → `nodeId` → 按段落切片 → 每片 `doc update --node --content-file --mode append`
3. **回读校验**:`doc read --node ` 校验内容完整性 | +| update-doc-section | 1. `doc search --query "<关键词>"` → 取 `nodeId`
2. `doc read --node ` 定位目标章节
3. `doc update --node --content "<替换内容>" --mode overwrite`
4. **回读校验**:`doc read --node ` 确认 overwrite 未被降级为 append、内容完整无截断
**overwrite 须用户确认** | +| doc-to-message | 1. `doc read --node ` → 取正文(大文档只摘要+链接)
2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | + +--- + +## 分块 append 截断风险提示 + +### 触发条件 + +当内容总大小 **超过 200KB**,需要拆分为多片通过 `doc update --mode append` 分块写入时,**必须在执行前向用户发出截断风险提示**,等待用户确认后再继续。 + +### 提示话术(参考模板) + +> 注意: 内容较长(约 {size}),需要分 {n} 片写入。分块 append 存在以下风险: +> - 部分片段可能写入失败但返回 success,导致文档**内容截断或缺失** +> - 片段之间的表格、代码块等跨块元素可能**被截断破坏** +> - 写入顺序异常可能导致**段落错乱** +> +> 建议:写入完成后我会回读校验文档完整性。是否继续? + +### 执行规范 + +1. **提示时机**:在执行第一片 append **之前**提示,而非写入过程中 +2. **分片原则**:按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断 +3. **逐片校验**(推荐):每写入一片后记录已写入的最后一个标题/段落标记,供最终回读时比对 +4. **最终回读**(必须):所有片段写入完成后,执行 `doc read --node ` 回读全文,逐片比对关键标记是否完整(详见下方「doc update 回读校验规范」) +5. **失败处理**:若回读发现缺失片段,向用户报告具体缺失位置,建议针对缺失部分单独重试 append + +--- + +## doc update 回读校验规范 + +### 为什么需要回读校验 + +`doc update` 在以下场景可能产生**静默失败**,返回 `success=true` 但实际写入不完整: + +- **overwrite 降级为 append**:大文档 overwrite 模式被后端静默降级,导致旧内容未清除、新内容追加在末尾 +- **分块 append 内容截断**:超长文档分片写入时,部分片段丢失或顺序错乱 +- **编码/通道问题**:PowerShell 等特殊终端下 UTF-8 内容传输乱码 + +### 校验流程 + +在 **每次 `doc update` 完成后**(无论 overwrite 还是 append 模式),必须执行以下步骤: + +1. **回读文档**:`doc read --node ` +2. **关键内容校验**:检查回读结果是否包含预期的关键标题、段落首句、表格标记等 +3. **异常处理**: + - 若内容缺失或截断 → 向用户报告具体差异,建议重试或手动修复 + - 若 overwrite 后仍残留旧内容 → 提示用户 overwrite 可能被降级,建议先清空再 append + - **禁止**在未回读的情况下直接向用户报告"已完成" + +### 示例 + +``` +# 更新文档 +doc update --node abc123 --content-file /tmp/new-content.md --mode overwrite + +# 回读校验 +doc read --node abc123 +# → 检查输出是否包含预期内容的关键标题和段落 +``` diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md new file mode 100644 index 00000000..2a7cb077 --- /dev/null +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -0,0 +1,910 @@ +> ⚠️ **CLI 暴露状态(开源 dws v1.0.30)**:本文档列出的 `doc export` / `doc export get` / `doc permission add` 等子命令在开源 dws 未暴露。开源 `dws doc` 暴露的子命令:`block / comment / copy / create / download / file / folder / info / list / move / read / rename / search / update / upload`。导出功能用 `dws doc download`;权限管理在开源版本由 wiki 容器级 (`dws wiki member`) 控制,节点级 doc 权限暂不支持。 + + +# 文档 (doc) 命令参考 + +## 查询命令帮助 + +当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 + +```bash +# 查看 doc 下所有子命令 +dws doc --help + +# 查看具体命令的完整参数说明 +dws doc list --help +dws doc create --help +dws doc block insert --help + +# 查看子命令组下的所有命令 +dws doc block --help +dws doc permission --help +``` + +规则: +- 参数名不确定时 → 先 `--help`,再调用 +- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 +- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表 + +## 命令总览 + +### 搜索文档 +``` +Usage: + dws doc search [flags] +Example: + dws doc search --query "会议纪要" + dws doc search + dws doc search --extensions pdf,docx + dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000 + dws doc search --creator-uids uid1,uid2 + dws doc search --workspace-ids wsId1,wsId2 +Flags: + --query string 搜索关键词 (不传则返回最近访问) + --extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。支持的在线文档类型后缀名: adoc=文字, axls=表格, appt=演示文稿, awbd=白板, adraw=画板, amind=脑图, able=多维表格, aform=收集表 + --created-from int 创建时间起始 (毫秒时间戳,含) + --created-to int 创建时间截止 (毫秒时间戳,含) + --visited-from int 访问时间起始 (毫秒时间戳,含) + --visited-to int 访问时间截止 (毫秒时间戳,含) + --creator-uids strings 按创建者用户 ID 过滤,逗号分隔 + --editor-uids strings 按编辑者用户 ID 过滤,逗号分隔 + --mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔 + --workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔 + --page-size int 每页数量 (默认 10,最大 30) + --page-token string 分页游标 (从上次结果的 nextPageToken 获取) +``` + +### 遍历文件列表 +``` +Usage: + dws doc list [flags] +Example: + dws doc list + dws doc list --folder + dws doc list --workspace --page-size 20 +Flags: + --folder string 文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 知识库 ID + --page-size int 每页数量 (默认 50,最大 50) + --page-token string 分页游标 (从上次结果的 nextPageToken 获取) +``` + +### 获取文档元信息 +``` +Usage: + dws doc info [flags] +Example: + dws doc info --node + dws doc info --node "https://alidocs.dingtalk.com/i/nodes/" + dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=" + dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=" +Flags: + --node string 文档 ID 或 URL (必填) +``` + +### 读取文档内容 +``` +Usage: + dws doc read [flags] +Example: + dws doc read --node + dws doc read --node "https://alidocs.dingtalk.com/i/nodes/" + dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=" + dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=" +Flags: + --node string 文档 ID 或 URL (必填) +``` + +### 创建文档 + +> 路由前置判断(创建类意图必看):`dws doc create` 只能创建在线文字文档(adoc),不要用它承接所有「新建 xxx」请求。收到「创建/新建」类需求时,必须先按文件类型分流: +> - 用户说「创建表格 / 新建表格 / 建个电子表格 / 在线表格 / 销售数据表」等 → 加载 `dingtalk-sheet`,走 `dws sheet create`(钉钉在线电子表格 `axls`),不要走 `doc create` +> - 用户说「创建多维表格 / 新建 AI 表格 / 建个 base / 数据库表」等 → 加载 `dingtalk-aitable`,走 `dws aitable base create`(多维表格 `able`),不要走 `doc create` +> - 用户说「创建文档 / 新建文档 / 写篇文档 / 会议纪要 / 周报 / 方案」等文字型内容 → 才走 `dws doc create` +> +> 一句话口诀:表格 → sheet/aitable;文档 → doc。 + +``` +Usage: + dws doc create [flags] +Example: + dws doc create --name "项目周报" + dws doc create --name "Q1 总结" --content "# Q1 总结" --folder + dws doc create --name "知识库文档" --workspace + dws doc create --name "周报" --content-file ./weekly.md --folder + cat report.md | dws doc create --name "月报" --content - +Flags: + --name string 文档名称 (必填) + --folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 目标知识库 ID + --content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取 + --content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用 +``` + +### 更新文档内容 +``` +Usage: + dws doc update [flags] +Example: + dws doc update --node --content "# 追加内容" --mode append + dws doc update --node --content "# 完整替换" --mode overwrite + dws doc update --node --content-file ./part1.md --mode append + dws doc update --node --content "# 插入到第3个block前" --mode append --index 2 + cat part2.md | dws doc update --node --content - --mode append +Flags: + --node string 文档 ID 或 URL (必填) + --content string 文档内容(短文本字面量);传 - 表示从 stdin 读取 + --content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用 + --mode string 更新模式: overwrite=覆盖, append=追加 (必填) + --index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1 +``` + +### 内容写入管道(create / update 共用) + +> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。 + +#### 输入方式选择 + +| 场景 | 推荐方式 | 说明 | +|------|---------|------| +| 短文本(<2KB,无换行/表格/特殊字符) | `--content "..."` | 字面量传入,最简单 | +| 长文本(≥2KB)、含换行、含表格 | `--content-file ./file.md` | **必须**用文件路径,避免 shell escape 和截断 | +| 含特殊字符(`"`、`\`、`$`、`` ` ``) | `--content-file ./file.md` | 字面量传入会被 shell 转义破坏 | +| 管道/heredoc 输入 | `--content -` 或 `cat file \| dws doc ...` | 从 stdin 读取 | + +#### 自动分片行为 + +当内容超过 30000 字符时,CLI 自动执行: +1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append +2. **update (overwrite)**: 第一片用 overwrite,后续片用 append +3. **update (append)**: 所有片段用 append + +分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性) + +如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。 + +#### 输出格式 + +写入成功后输出 JSON(混合 `[INFO]` 进度行): + +```json +{"success": true, "nodeId": "xxx", "chunksWritten": 3} +``` + +| 字段 | 说明 | +|------|------| +| `nodeId` | 文档节点 ID,可用于后续读取或追加 | +| `chunksWritten` | 实际写入的分片数(1 = 单次写入) | + +#### 内容完整性验证(必读) + +CLI **不会**自动执行回读验证。**你必须在文档写入完成后主动回读确认**: + +1. 使用 `dws doc read --node ` 读取写入后的文档内容 +2. 检查关键段落是否完整、顺序是否正确 +3. 如发现内容缺失或异常,使用 `dws doc update --mode append` 补写缺失部分 + +> **何时回读**:每次 create/update 操作完成后都应回读。如果是连续多次编辑同一文档,可以在全部编辑完成后统一回读一次。 + +#### 进度输出示例 + +``` +[INFO] 内容较长 (45000 字符),自动分片写入... +[INFO] 已创建空文档 (nodeId=abc123),开始分片写入... +[INFO] 写入分片 (1/3),15000 字符... +[INFO] 写入分片 (2/3),15000 字符... +[INFO] 写入分片 (3/3),15000 字符... +[INFO] 全部 3 个分片写入完成 +{"success": true, "nodeId": "abc123", "chunksWritten": 3} +``` + +#### CONTENT_TRUNCATED 错误 + +当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略: +1. 检查网络和后端服务状态 +2. 已写入的部分内容可通过 `dws doc read --node ` 查看 +3. 从断点处手动用 `dws doc update --mode append` 继续追加 + +### 上传文件到钉钉文档或钉钉知识库 +``` +Usage: + dws doc upload [flags] +Example: + dws doc upload --file ./report.pdf + dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder + dws doc upload --file ./data.xlsx --workspace --convert +Flags: + --file string 本地文件路径 (必填) + --name string 文件显示名称 (默认使用文件名) + --folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 目标知识库 ID + --convert 是否转换为钉钉在线文档 +``` + +### 下载或导出文件到本地 +> **用户说"下载"或"导出"时,必须先用 `info --node --format json` 查询 `contentType`,再选择正确的命令:** +> - `contentType` 为 **`ALIDOC`**(在线文档)→ **必须用 `export`**,禁止用 `download` +> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 `download` + +**步骤 1(必须):先查类型** +``` +dws doc info --node --format json +``` +**步骤 2:根据 contentType 选命令** +- 如果是 ALIDOC(在线文档)→ 暂不支持导出为本地 docx;用 `dws doc read --node ` 拿到内容自己处理 +- 如果是非 ALIDOC → `dws doc download --node --output <路径>` + +download 命令(仅限非 ALIDOC 文件): +``` +Usage: + dws doc download [flags] +Example: + dws doc download --node + dws doc download --node --output ./report.pdf + dws doc download --node "https://alidocs.dingtalk.com/i/nodes/" --output ~/downloads/ +Flags: + --node string 文件节点 ID 或 URL (必填) + --output string 本地保存路径 (文件路径或目录,必填) +``` + +### 复制文档/文件 +``` +Usage: + dws doc copy [flags] +Example: + dws doc copy --node --folder + dws doc copy --node --workspace + dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/" --folder +Flags: + --node string 文档/文件 ID 或 URL (必填) + --folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录) +``` + +### 移动文档/文件 +``` +Usage: + dws doc move [flags] +Example: + dws doc move --node --folder + dws doc move --node --workspace + dws doc move --node "https://alidocs.dingtalk.com/i/nodes/" --folder +Flags: + --node string 文档/文件 ID 或 URL (必填) + --folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录) +``` + +### 重命名文档/文件 +``` +Usage: + dws doc rename [flags] +Example: + dws doc rename --node --name "新名称" + dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/" --name "项目周报 v2" +Flags: + --node string 文档/文件 ID 或 URL (必填) + --name string 新名称 (必填) +``` + +### 删除文档/文件到回收站 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws doc delete [flags] +Example: + dws doc delete --node --format json # 查询 nodeId: dws doc search --query "..." 或 dws doc list +Flags: + --node string 文档/文件 ID 或 URL (必填) +``` + +权限要求: 对文档有"管理"权限。 + +### 创建文件夹 +``` +Usage: + dws doc folder create [flags] +Example: + dws doc folder create --name "项目资料" + dws doc folder create --name "子文件夹" --folder +Flags: + --name string 文件夹名称 (必填) + --folder string 父文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID + --workspace string 目标知识库 ID +``` + +### 查询块元素 +``` +Usage: + dws doc block list [flags] +Example: + dws doc block list --node + dws doc block list --node --start-index 0 --end-index 5 + dws doc block list --node --block-type heading +Flags: + --node string 文档 ID 或 URL (必填) + --start-index int 起始位置 (从 0 开始) + --end-index int 终止位置 (含) + --block-type string 按块类型过滤 +``` + +### 插入块元素 +``` +Usage: + dws doc block insert [flags] +Example: + dws doc block insert --node --text "这是一段文字" + dws doc block insert --node --heading "二级标题" --level 2 + dws doc block insert --node --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}' + dws doc block insert --node --text "在此处之前插入" --ref-block --where before + + # 插入引用块(blockquote) + dws doc block insert --node --element '{"blockType":"blockquote","blockquote":{"text":"这是一段引用内容"}}' + + # 插入分栏块(columns):2 栏,children 为每栏的内容 + dws doc block insert --node --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}' + + # 插入表格(table):2 行 3 列 + dws doc block insert --node --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}' + + # 插入行内图片(inline image):构造一个空 paragraph,在 children 中用 image elementType 指定图片 src + dws doc block insert --node --element '{"blockType":"paragraph","paragraph":{},"children":[{"elementType":"image","properties":{"src":"https://example.com/photo.png"}}]}' + + # 插入附件块(attachment):resourceId 为资源ID,type 为 MIME 类型,viewType 可选 preview/summary + dws doc block insert --node --element '{"blockType":"attachment","attachment":{"resourceId":"12345-xxx-xxx-123-xxxxx","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}' + + # 插入分割线:使用 doc update --mode append 以 Markdown 格式追加 + dws doc update --node --content "---" --mode append +Flags: + --node string 文档 ID 或 URL (必填) + --text string 快捷: 段落文本内容 + --heading string 快捷: 标题文本 + --level int 标题级别 1-6 (配合 --heading,默认 1) + --element string 块元素 JSON (高级) + --index int 参照位置索引 (从 0 开始) + --where string 插入方向: before / after (默认 after) + --ref-block string 参照块 ID (优先级高于 --index) +``` + +### 更新块元素 +``` +Usage: + dws doc block update [flags] +Example: + dws doc block update --node --block-id --text "新内容" + dws doc block update --node --block-id --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}' +Flags: + --node string 文档 ID 或 URL (必填) + --block-id string 目标块 ID (必填) + --text string 快捷: 段落文本内容 + --heading string 快捷: 标题文本 + --level int 标题级别 1-6 (配合 --heading,默认 1) + --element string 块元素 JSON (高级) +``` + +### 删除块元素 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws doc block delete [flags] +Example: + dws doc block delete --node --block-id --yes +Flags: + --node string 文档 ID 或 URL (必填) + --block-id string 目标块 ID (必填) +``` + +### 查询文档评论列表 +``` +Usage: + dws doc comment list [flags] +Example: + dws doc comment list --node + dws doc comment list --node --type inline --resolve-status unresolved + dws doc comment list --node --page-size 20 --next-token +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --page-size int 每页返回的评论数量,默认 50,最大 50 + --next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) + --type string 按评论类型过滤: global (全文评论) / inline (划词评论) + --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) +``` + +### 创建文档评论 +``` +Usage: + dws doc comment create [flags] +Example: + dws doc comment create --node --content "这里需要修改" + dws doc comment create --node --content "请review" --mention uid1,uid2 +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --content string 评论的文字内容,纯文本 (必填) + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + +### 回复文档评论 +``` +Usage: + dws doc comment reply [flags] +Example: + dws doc comment reply --node --comment-key --content "同意" + dws doc comment reply --node --comment-key --content "比心" --emoji + dws doc comment reply --node --comment-key --content "请确认" --mention uid1,uid2 +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --content string 回复的文字内容,表情回复时填写表情名称 (必填) + --comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填) + --emoji 设为 true 时作为表情贴图回复 (默认 false) + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + +### 下载文档附件 +``` +Usage: + dws doc media download [flags] +Example: + dws doc media download --node --resource-id + dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/xxx" --resource-id +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --resource-id string 附件资源 ID,可通过 dws doc block list 获取 (必填) +``` + +### 上传附件并插入文档 +``` +Usage: + dws doc media insert [flags] +Example: + dws doc media insert --node --file ./report.pdf + dws doc media insert --node --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream + dws doc media insert --node --file ./image.png --ref-block --where before +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --file string 本地文件路径 (必填) + --name string 附件显示名称 (默认使用文件名) + --mime-type string 文件 MIME 类型 (默认根据扩展名推断) + --index int 插入位置索引 + --where string 相对位置: before / after (配合 --ref-block) + --ref-block string 参考块 ID (配合 --where) +``` + +### 创建划词评论 +``` +Usage: + dws doc comment create-inline [flags] +Example: + dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" + dws doc comment create-inline --node --block-id --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文" + dws doc comment create-inline --node --block-id --start 0 --end 10 --content "请review" --mention uid1,uid2 +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --block-id string 评论标记所在的块 ID,可通过 dws doc block list 获取 (必填) + --start int 评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填) + --end int 评论标记在块内文本中的结束字符偏移量,必须大于 start (必填) + --content string 评论的文字内容,纯文本 (必填) + --selected-text string 选中文本的内容,填写后评论列表中会展示「引用原文:xxx」 + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + +## URL 识别与 DOC_ID 提取 + +当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。 + +补充:如果这是用户直接提供的原始 `alidocs` URL,必须先按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」probe 一次确认真实类型,再判断是否继续走 `doc`。 + +### 支持的 URL 格式 + +| 格式 | 示例 | DOC_ID 提取方式 | +|------|------|----------------| +| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | +| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` | +| `alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | `https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE` | **不要提取 `dentryKey` 单独使用**,必须将完整 URL 原样传给 `--node` | + +### 提取规则 + +1. 匹配 URL 中 `alidocs.dingtalk.com` 域名 +2. 路径为 `/i/nodes/{id}` 时,取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment) +3. 路径为 `/document/edit` 或 `/document/preview` 且 query 含 `dentryKey` 时,**禁止**提取 `dentryKey` 当 DOC_ID;将整段 URL 原样传给 `--node`,CLI 会自动解析(追踪参数如 `utm_source`、`chInfo` 也不必清理) +4. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析) +5. 对用户直接提供的原始 `alidocs` URL(特别是 `/i/nodes/` 格式),先按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」执行 probe;只有 probe 确认是 `adoc` / `file` / `folder` 时,才继续走 `doc` + +### ID 边界与参数映射 + +- `nodeId` 是 `doc` 命令的统一节点标识;文档、文件夹、文件都通过 `nodeId` 或完整 `alidocs` URL 传给 `--node` / `--folder`。 +- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。 +- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。 +- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder `,目标知识库使用 `--workspace `。 +- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `doc search` / `doc list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。 + +### 处理流程 + +``` +用户输入含 alidocs.dingtalk.com URL + → 若是用户直接提供的原始 URL,先按链接规范做 probe + → 提取 DOC_ID(URL 路径最后一段) + → 结合用户意图选择命令(doc 默认 read,folder 默认 list,file 默认 download) + → 将 DOC_ID 传给 --node 参数 +``` + +## 意图判断 + +用户说"找文档/搜文档/最近文档": +- 搜索 → `search` +- 浏览 → `list` + +用户说"看文档/读内容/文档内容": +- 读取 → `read` (需文档 ID 或 URL) +- 元信息 → `info` + +用户说"写文档/创建文档": +- 新建文字文档(adoc)→ `doc create` +- 追加内容 → `update --mode append` +- 覆盖替换 → `update --mode overwrite` +- 指定父目录时,只有明确的文档文件夹 `nodeId` / `alidocs` 文件夹 URL 才能放入 `--folder`;上一步若只返回了纯数字 `dentryId`、`spaceId` 或 drive `parent-id`,不要把它传给 doc 的 `--folder` + +> 严禁把「创建表格」路由到 `doc create`: +> - 用户说"创建表格/新建表格/建个电子表格/在线表格" → 加载 `dingtalk-sheet`,走 `dws sheet create`(axls 在线电子表格) +> - 用户说"创建多维表格/新建 AI 表格/建 base/数据库表" → 加载 `dingtalk-aitable`,走 `dws aitable base create`(able 多维表格) + +用户说"建文件夹/新建目录": +- 创建 → `folder create` + +用户说"上传文件/传文件/上传到文档/上传到知识库": +- 上传 → `upload`(需本地文件路径) +- 上传并转换 → `upload --convert` + +用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来": +- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`: + - 在线文档 (alidocs/adoc) → **`export`**(格式转换后导出为 docx) + - 已有文件(PDF、图片、附件、视频等非在线文档) → **`download`**(直接下载原始文件) +- 判断方法: + 1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `export` + 2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `download` + 3. 不确定时,先用 `info --node ` 查询节点信息,根据返回的 `contentType` 字段判断: + - `contentType` 为 `ALIDOC` → 走 `export` + - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `download` + +> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx,两者完全不同。 + +用户说"复制文档/拷贝文件/复制到": +- 复制 → `copy`(需文档 ID 或 URL + 目标位置) + +用户说"移动文档/搬到/移到/转移文件": +- 移动 → `move`(需文档 ID 或 URL + 目标位置) + +用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...": +- 重命名 → `doc rename`(需文档 ID 或 URL + 新名称) +- 只要意图是修改文档在列表和链接中展示的名称,统一路由到 `dws doc rename --node --name "新名称"`;不要走 `drive`、`doc update` 或重新 `doc create`。 +- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。 + +用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档": +- 删除节点 → `delete`(危险操作,需确认;需文档 ID 或 URL) + +用户说"插入附件/上传附件到文档/往文档里加文件/加附件": +- 插入附件 → `media insert`(需文档 ID 或 URL + 本地文件路径) + +用户说"插入图片/加图片/放张图/嵌入图片/往文档里插图": +- 插入图片 → `media insert`(需文档 ID 或 URL + 本地图片文件路径) +- 注意:图片也是通过 `media insert` 作为附件块插入文档,不是通过 `block insert` + +用户说"下载附件/获取附件/取出文档里的附件": +- 下载附件 → `media download`(需文档 ID 或 URL + 资源 ID) + +用户说"编辑块/改段落/插入标题/删除块": +- 查看结构 → `block list` +- 插入 → `block insert` +- 修改 → `block update` +- 删除 → `block delete` + +用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看": +- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`) +- 修改权限 → `permission update` +- 查看谁有权限 → `permission list` + +> **关键区分**: +> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持) +> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**) + +> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」probe;只有 probe 确认是 `adoc` / `file` / `folder` 后,才继续按下列意图执行。 + +**用户直接粘贴文档 URL(无其他指令)**: +- 默认 → `read`(读取文档内容) +- 如 URL 明显是文件夹 → `list`(列出文件夹内容) + +**用户粘贴 URL + 附加指令**: +- "帮我看看这个文档" → `read` +- "这个文档的信息" → `info` +- "往这个文档追加内容" → `update --mode append` +- "把这个文档标题改成 X" / "这个文档改名为 X" → `rename` +- "把正文里的一级标题/章节标题改成 X" → `block update` + +关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理) + +## 核心工作流 + +```bash +# ── 工作流 1: 浏览并阅读文档 ── + +# 1. 浏览我的文档根目录 +dws doc list --format json + +# 2. 浏览子文件夹 +dws doc list --folder --format json + +# 3. 获取文档元信息 (标题、类型、权限) +dws doc info --node --format json + +# 4. 读取文档内容 (Markdown 格式) +dws doc read --node --format json + +# ── 工作流 2: 创建文档并写入内容 ── + +# 1. (可选) 创建文件夹 — 提取 nodeId +dws doc folder create --name "项目资料" --format json + +# 2. 创建文档 — 提取 nodeId +dws doc create --name "项目周报" --folder --format json + +# 3. 写入内容 (追加模式) +dws doc update --node --content "# 本周总结\n\n- 完成了 A\n- 推进了 B" --mode append --format json + +# ── 工作流 3: 一步创建带内容的文档 ── + +dws doc create --name "会议纪要" --content "# 会议纪要\n\n## 议题\n\n1. ..." --format json + +# ── 工作流 4: 上传本地文件到钉钉文档/知识库 ── + +# 1. 上传到"我的文档"根目录 +dws doc upload --file ./report.pdf + +# 2. 上传到指定文件夹 +dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder + +# 3. 上传到知识库并转换为在线文档 +dws doc upload --file ./data.xlsx --workspace --convert + +# ── 工作流 5: 下载/导出文件到本地 ── +# 必须先用 info 判断文件类型,再决定用 download 还是 export: +# - contentType 为 ALIDOC(在线文档)→ 用 export +# - contentType 为 DOCUMENT/IMAGE/VIDEO 等(已有文件)→ 用 download + +# 步骤 1: 查询文件类型 +dws doc info --node --format json +# 根据返回的 contentType 字段判断: + +# 如果是已有文件 (非 ALIDOC),用 download: +dws doc download --node --output ~/downloads/ + +# 如果是在线文档 (ALIDOC),用 export: +# dws doc export 在开源 v1.0.30 不可用——在线文档暂不支持 CLI 导出;可读取内容后自行写入本地 + +# ── 工作流 6: 上传附件并插入文档 ── + +# media insert 自动完成三步流程: +# 1. 获取附件上传凭证 (get_doc_attachment_upload_info) +# 2. HTTP PUT 上传文件到 OSS +# 3. 插入附件块到文档 (insert_document_block) + +# 1. 基本用法: 插入本地文件到文档 +dws doc media insert --node --file ./report.pdf + +# 2. 指定附件显示名称 +dws doc media insert --node --file ./data.xlsx --name "Q1数据报表.xlsx" + +# 3. 指定 MIME 类型 (文件扩展名无法推断时) +dws doc media insert --node --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream + +# 4. 在指定块之前插入附件 +dws doc media insert --node --file ./image.png --ref-block --where before + +# 5. 完整流程: 创建文档 → 写入内容 → 插入附件 +dws doc create --name "项目报告" --content "# 项目报告\n\n以下为相关附件:" --format json +# 提取 nodeId 后: +dws doc media insert --node --file ./design.pdf +dws doc media insert --node --file ./timeline.xlsx --name "项目时间线.xlsx" + +# ── 工作流 7: 块级精细编辑 ── + +# 1. 查看文档块结构 — 获取 blockId +dws doc block list --node --format json + +# 2. 在文档末尾插入段落 +dws doc block insert --node --text "新增内容" + +# 3. 在指定块之前插入标题 +dws doc block insert --node --heading "新章节" --level 2 --ref-block --where before + +# 4. 更新某个块的内容 +dws doc block update --node --block-id --text "修改后的内容" + +# 5. 删除块 +dws doc block delete --node --block-id --yes + +# ── 工作流 8: 复制/移动/重命名文档 ── + +# 获取 nodeId 的三种方式(按场景选择,无需全部执行): +# 方式 A: 用户直接提供文档 URL — 直接传给 --node,无需额外查询 +# 方式 B: 搜索文档 — 从返回中提取 nodeId +dws doc search --query "项目周报" --format json +# 方式 C: 浏览文件夹 — 从返回中提取 nodeId +dws doc list --folder --format json +# 注意: 这里的 是文件夹 nodeId/dentryUuid 或文件夹 URL,不是数字 dentryId;doc 命令没有 --parent-id。 + +# 复制文档到指定文件夹(--node 支持 ID 或 URL) +dws doc copy --node --folder --format json + +# 复制到目标知识库根目录(不传 --folder 时) +dws doc copy --node --workspace --format json + +# 移动文档到指定文件夹 +dws doc move --node --folder --format json + +# 移动到目标知识库根目录(不传 --folder 时) +dws doc move --node --workspace --format json + +# 重命名文档 +dws doc rename --node --name "新名称" --format json + +# 删除文档到回收站(危险操作:必须先向用户确认,用户同意后才加 --yes 执行) +# 正确流程:1.向用户展示"即将删除「文档名」到回收站" → 2.等用户确认 → 3.执行下面命令 +dws doc delete --node --yes --format json + +# ── 工作流 9: 文档评论管理 ── + +# 1. 查看文档的所有评论 +dws doc comment list --node --format json + +# 2. 在文档上创建评论 +dws doc comment create --node --content "这里需要补充数据来源" --format json + +# 3. 创建评论并 @ 相关人 +# 先搜索用户: dws contact user search --query "张三" --format json → 提取 userId +# 再将 userId 传入 --mention +dws doc comment create --node --content "请确认这部分内容" --mention , --format json + +# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取) +dws doc comment reply --node --comment-key --content "已修改" --format json + +# 5. 用表情回复评论 +dws doc comment reply --node --comment-key --content "比心" --emoji --format json + +# 6. 创建划词评论(针对文档中某段选中文本) +# 先获取块列表: dws doc block list --node --format json → 提取 blockId 和文本内容 +# 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end) +dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" --format json + +# 7. 创建划词评论并附带引用原文 + @ 相关人 +dws doc comment create-inline --node --block-id --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention , --format json + +# ── 工作流 10: 导出在线文档为 docx ── + +# 一条命令自动完成(提交→轮询→下载),无需手动编排 +# dws doc export 在开源 v1.0.30 不可用 + +# 如果导出命令超时或中断,可用 export get 手动查询任务状态: +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `list` | `nodes[].nodeId` | read / info / update / copy / move / rename / block 操作的 --node | +| `list` | folder 类型的 `nodeId` | list 的 --folder, create / copy / move 的 --folder | +| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update / copy / move / rename 的 --node;创建时间与创建者信息 | +| `create` | `nodeId` | update / block 操作的 --node | +| `folder create` | `nodeId` | create / list / upload / copy / move 的 --folder | +| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id | +| `upload` | `nodeId` / URL | 上传后文件的访问链接 | +| `download` | 本地文件路径 | 下载后的文件保存位置 | +| `copy` | `nodeId` / URL (异步,不保证返回) | 复制是异步任务,若任务未完成则不会返回新文档 ID;如需获取可稍后通过 list 查询目标文件夹 | +| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 | +| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 | +| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id | +| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key | +| `comment create` | `commentKey` | comment reply 的 --comment-key | +| `comment create-inline` | `commentKey` | comment reply 的 --comment-key | +| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id | +| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 | +| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention | + +## nodeId 双格式说明 + +所有 `--node` 参数同时支持两种格式,系统自动识别: +- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` +- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` + +两种方式等价,以下命令效果相同: +```bash +dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA +dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA" +``` + +`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。 + +不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws doc list/search` 找到文档文件夹 nodeId。 + +## 注意事项 + +- `export` 是一体化命令,一条命令自动完成提交→轮询→下载,**无需手动编排轮询**。CLI 内部使用渐进式退避轮询(最多约 5 分钟) +- `export` 当前仅支持钉钉在线文档 (alidocs) 导出为 `docx`,在线表格导出请使用其他命令 +- `update` 必须显式指定 `--mode`(overwrite 或 append),不再有默认值。`--mode overwrite` 会**清空原内容后重写**,谨慎使用;`--mode append` (追加) 更安全 +- `read` 返回 Markdown 格式的文档内容,仅限有"下载"权限的文档 +- `read` 返回的内容中,文档里的附件会以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/.ext?Expires=...`),该链接会过期。链接过期后,可从 URL 路径中提取 ``(即 `/att/` 后、扩展名前的 UUID 部分),然后使用 `media download --node --resource-id ` 重新获取下载链接 +- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录 +- `block list/insert/update/delete` 是块级精细编辑,适合结构化修改;简单内容追加建议用 `update --mode append` +- `block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型 (table, callout 等) 使用 `--element` JSON +- `--content` 参数中的换行必须使用**真实换行符**(即实际的换行字符,Unicode `U+000A`),而不是字面量字符串 `\n`(反斜杠加字母 n)。在通过程序或大模型构造此参数时,请确保字符串在发送前已正确反转义。如果传入的是两个字符的字面量 `\n`,所有内容将渲染在同一行,导致标题、段落和表格格式全部错乱。**含多行/表格/长文本时优先用 `--content-file path.md` 或 `--content -`(stdin),不经过 shell escape,换行和表格都保持原样**(详见下方「长 Markdown 写入」)。 +- 块类型包括: paragraph, heading, blockquote, callout, columns, orderedList, unorderedList, table, sheet, attachment, slot +- 关键区分: doc(文档内容级操作) vs wiki(知识库空间级管理) vs aitable(数据表格操作) vs drive(钉盘文件管理) +- wiki 是知识库容器,doc 是知识库中的文档内容;需要 `workspaceId` 时,先用 `dws wiki space list/search` 获取,再传给 doc 的 `--workspace` 参数 +- `doc upload vs drive upload-info`:用户提到"知识库/文档空间/workspace"→ `doc upload`;提到"钉盘/网盘/我的文件"→ `drive upload-info` + `drive commit`(开源 v1.0.30 是两步上传) +- `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档 +- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作 +- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录 +- `media insert` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 插入附件块到文档),无需手动分步操作 +- `media insert` 的 `--mime-type` 可选,不指定时根据文件扩展名自动推断;支持常见文件类型 (PDF、Office、图片、视频、压缩包等) +- `media insert` 与 `upload` 的区别:`upload` 将文件上传到文档空间/知识库作为独立文件;`media insert` 将文件作为附件块插入到文档正文中 +- `media download` 用于获取文档正文中附件的临时下载链接,`--resource-id` 可通过 `block list` 返回的 attachment 块获取 +- `copy` 需要对源文档有"阅读"权限,且对目标文件夹有"编辑"权限 +- `move` 需要对源文档有"管理"权限,且对目标文件夹有"编辑"权限;移动后原位置的文档将不再存在 +- `rename` 需要对文档有"编辑"权限 + +## 长 Markdown 写入 + +**核心规则**:含多行、表格、`\n` 或长度 >2KB 的 Markdown **必须**通过 `--content-file` 或 `--content -`(stdin)传入,禁止直接作为 `--content` 命令行字符串——shell escape 会破坏换行和表格,且命令行长度受限。 + +`dws doc create` 和 `dws doc update` 支持两种内容来源(`--content-file` 优先于 `--content`): + +| 形式 | 说明 | +|------|------| +| `--content "..."` | 字面量(仅推荐短文本 <2KB 且无换行/表格) | +| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) | +| `--content-file path` | 从文件读取(UTF-8),推荐 | + +### 短/中等长度(< 200KB)— 单步创建 + +```bash +# 1. 把内容写入 UTF-8 文本文件: +# Linux/Mac: /tmp/.md;Windows: %TEMP%\.md +# 2. 一步创建+写入: +dws doc create --name "<文档名>" --content-file [--folder ] [--workspace ] +``` + +### 超长(> 200KB 兜底)— 创建空文档 + 分片追加 + +```bash +# 1. 创建空文档拿 nodeId +dws doc create --name "<文档名>" [--folder/--workspace] # → nodeId + +# 2. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格) +# 3. 逐个追加: +dws doc update --node --content-file --mode append +``` + +### stdin 变体 + +```bash +# pipe +cat report.md | dws doc update --node --content - --mode append + +# heredoc(真实换行,含表格) +dws doc update --node --mode append --content - <<'EOF' +## 追加段落 + +| 列1 | 列2 | +|---|---| +| a | b | +EOF +``` + +## 相关产品 + +- `dingtalk-wiki` (`references/wiki.md`) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中 +- `dingtalk-aitable` (`references/aitable.md`) — 结构化数据表格(行列/字段/记录),不是富文本文档 +- `dingtalk-drive` (`references/drive.md`) — 钉盘文件存储/上传/下载,不是文档内容编辑 +- `dingtalk-report` (`references/report.md`) — 钉钉日志系统(日报/周报模版),不是在线文档 diff --git a/skills/multi/dingtalk-doc/references/url-patterns.md b/skills/multi/dingtalk-doc/references/url-patterns.md new file mode 100644 index 00000000..189a4516 --- /dev/null +++ b/skills/multi/dingtalk-doc/references/url-patterns.md @@ -0,0 +1,127 @@ +# URL 格式与处理规范 + +## alidocs URL 分流决策(必须首先执行) + +收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**: + +1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行 +2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行 +3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数 +4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可) +5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式 + +--- + +## 已知 URL 格式 + +需要自行拼接链接时,只能使用以下模板: + +| 产品 | 用途 | URL 格式 | ID 来源 | +|------|------|----------|---------| +| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` | +| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` | +| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` | +| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` | +| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` | +| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` | +| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` | + +不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。 + +## 分享短链处理 + +`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。 + +### 识别规则 + +URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如: +- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2` +- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7` +- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234` +- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789` + +> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。 + +### 处理方式 + +**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。 + +- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接 +- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入 + +``` +# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url) +read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2") +read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7") + +# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL) +dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json +dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json +``` + +### 当 `read_url` 返回内容不完整时 + +钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户: + +> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。 +> +> 你可以: +> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我 +> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取 + +--- + +## alidocs URL 类型探测流程 + +`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。 + +### 探测步骤 + +``` +Step 1 → dws doc info --node "" --format json +Step 2 → 从返回中提取 contentType、extension、nodeType 字段 +Step 3 → 按下方路由规则映射到对应产品 +``` + +### 路由映射表 + +| 条件 | 路由到产品 | 后续操作 | +|------|-----------|---------| +| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./doc.md) 操作 | +| `contentType=ALIDOC`, `extension=axls` | `sheet` | 切到 `dingtalk-sheet` skill,按其中 `references/sheet.md` 操作(仅 `axls` 在线电子表格) | +| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,切到 `dingtalk-aitable` skill 操作 | +| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | +| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 | +| `nodeType=folder` | `doc` | 调用 `dws doc list --folder ` 列出指定文件夹直接子节点列表 | +| 以上均不匹配 | — | 告知用户当前暂不支持该类型 | + +> axls vs xlsx 关键区分: +> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) +> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) +> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(envelope schema 里有 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx + +### 示例 + +```bash +# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123 +dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json + +# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet +dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json + +# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet) +dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" + +# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载 +dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456" + +# 返回 nodeType=folder → 文件夹,列出子节点 +dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json +``` + +### 何时可跳过探测 + +当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**: +- 用户只粘贴 URL,无其他上下文 +- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格) +- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型 diff --git a/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py b/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py new file mode 100644 index 00000000..367e4b93 --- /dev/null +++ b/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +在指定目录创建文档并写入 Markdown 内容(一键完成) + +用法: + python doc_create_and_write.py \ + --name "项目周报" \ + --content "# 本周总结\n\n## 完成事项\n- 任务A" + + python doc_create_and_write.py \ + --name "会议纪要" \ + --content-file notes.md + + python doc_create_and_write.py \ + --name "知识库文档" --content "# 内容" --folder FOLDER_ID + + python doc_create_and_write.py --name "test" --content "hello" --dry-run +""" + +import sys +import json +import time +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def run_dws_with_retry( + args: List[str], + dry_run: bool = False, + max_retries: int = 3, + retry_delay: float = 1.0, +) -> Optional[Any]: + """带重试机制的 dws 命令执行""" + last_error = None + for attempt in range(1, max_retries + 1): + result = run_dws(args, dry_run=dry_run) + if result is not None: + return result + if attempt < max_retries: + print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...") + time.sleep(retry_delay) + retry_delay *= 1.5 # 指数退避 + return None + + +def main(): + parser = argparse.ArgumentParser( + description='创建文档并写入内容' + ) + parser.add_argument('--name', required=True, help='文档名称') + parser.add_argument('--content', default='', help='Markdown 内容') + parser.add_argument('--content-file', default='', help='内容文件') + parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL') + parser.add_argument('--workspace', default='', help='目标知识库 ID') + parser.add_argument( + '--mode', default='append', choices=['overwrite', 'append'], + help='写入模式: overwrite=覆盖, append=追加 (默认 append)', + ) + parser.add_argument( + '--max-retries', type=int, default=3, + help='每块写入失败时的最大重试次数 (默认 3)', + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + content = args.content + if args.content_file: + p = Path(args.content_file) + if not p.exists(): + print(f"错误:文件不存在: {p}") + sys.exit(1) + content = p.read_text(encoding='utf-8') + if not content: + print('错误:需要 --content 或 --content-file') + sys.exit(1) + chunk_size = 30000 + + create_args = ['doc', 'create', '--name', args.name, '--format', 'json'] + if args.folder: + create_args.extend(['--folder', args.folder]) + if args.workspace: + create_args.extend(['--workspace', args.workspace]) + + print(f'\n📝 创建文档: {args.name}') + create_data = run_dws(create_args, dry_run=args.dry_run) + + node_id = None + if not args.dry_run: + if not create_data: + sys.exit(1) + node_id = (create_data.get('nodeId') + or create_data.get('dentryUuid') + or create_data.get('id', '')) + print(f" ✓ 文档已创建 (ID: {node_id})") + + if len(content) <= chunk_size: + mode_label = '追加' if args.mode == 'append' else '覆盖' + print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...') + write_data = run_dws([ + 'doc', 'update', + '--node', node_id or '', + '--content', content, + '--mode', args.mode, + '--format', 'json', + ], dry_run=args.dry_run) + if write_data: + print(f" ✓ 内容已写入 ({len(content)} 字符)") + else: + chunks = [] + pos = 0 + while pos < len(content): + end = min(pos + chunk_size, len(content)) + if end < len(content): + newline_pos = content.rfind('\n', pos, end) + if newline_pos > pos: + end = newline_pos + 1 + chunks.append(content[pos:end]) + pos = end + + total_chunks = len(chunks) + print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...') + + success_chunks = 0 + for idx, chunk in enumerate(chunks): + chunk_mode = args.mode if idx == 0 else 'append' + write_data = run_dws_with_retry( + [ + 'doc', 'update', + '--node', node_id or '', + '--content', chunk, + '--mode', chunk_mode, + '--format', 'json', + ], + dry_run=args.dry_run, + max_retries=args.max_retries, + ) + if write_data: + print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)") + success_chunks += 1 + elif not args.dry_run: + # 写入失败,报告部分写入状态 + print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)") + print(f"\n⚠️ 文档处于部分写入状态:") + print(f" - 文档 ID: {node_id}") + print(f" - 已写入: {success_chunks}/{total_chunks} 块") + print(f" - 失败位置: 第 {idx + 1} 块") + if args.mode == 'overwrite': + print(f" - 模式: 覆盖模式,文档可能包含不完整内容") + print(f" - 建议: 手动检查文档内容,或删除后重新创建") + else: + print(f" - 模式: 追加模式,已写入内容已保存") + print(f" - 建议: 可手动补充剩余内容,或重新运行脚本") + sys.exit(1) + print('\n✅ 完成!') + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-drive/SKILL.md b/skills/multi/dingtalk-drive/SKILL.md new file mode 100644 index 00000000..ee88f062 --- /dev/null +++ b/skills/multi/dingtalk-drive/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dingtalk-drive +description: 钉盘文件存储。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/创建文件夹。Distinct from dingtalk-doc(钉钉文档内容编辑)、dingtalk-wiki(知识库空间)。命令前缀:dws drive。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉盘 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[drive.md](references/drive.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "看钉盘空间 / 团队文件 / 有哪些 space" | `dws drive list-spaces` | +| "看钉盘文件 / 文件夹列表" | `dws drive list --space-id [--parent-id ]` | +| "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` | +| "查文件元数据" | `dws drive info --space-id --file-id ` | +| "下载文件" | `dws drive download --space-id --file-id ` | +| "上传本地文件(首选一键)" | `dws drive upload --file ./report.pdf [--folder ]` | +| "上传文件(手动三步)" | `dws drive upload-info --space-id --file-name <名> --file-size [--parent-id ]` → 客户端 HTTP PUT → `dws drive commit --space-id --upload-id --file-name <名> --file-size [--parent-id ]` | +| "建文件夹" | `dws drive mkdir --space-id --name "<名称>" [--parent-id ]` | +| "删除文件 / 移到回收站(需确认)" | `dws drive delete --node --yes` | + +## 评测高频硬约束 + +- 查找文件不要只看根目录后放弃;根目录没命中时,进入最相关的评测/目标文件夹继续 `drive list --space-id --parent-id `,必要时用目录树脚本递归到合理深度。 +- `drive list` 默认 `--max 20`,评测里保守使用 `--max 50` 以内并处理 `nextToken` 翻页;不要因为参数边界报错反复重试。 +- `dws drive` 当前没有 search 子命令,按目录递归 `drive list`;命中后必须 `drive info --space-id --file-id --format json` 回读元数据。 +- `drive download` 没有 `--output` flag,文件落地路径由 CLI 决定,必要时再用 shell 移动;不要拼写不存在的 flag。 +- 删除、覆盖、移动等破坏性操作必须确认;上传(upload-info + commit 两步)、创建文件夹、下载后要读回或列目录验证。 +- 所有 `dws drive` 命令加 `--format json`。 + +## 跨产品协作 + +- 文件内容编辑(钉钉文档)→ 切到 `dingtalk-doc` +- 知识库空间 → 切到 `dingtalk-wiki` diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md new file mode 100644 index 00000000..d8af5d70 --- /dev/null +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -0,0 +1,278 @@ +# 钉盘 (drive) 命令参考 + +> ✅ **CLI 对齐状态(开源 dws v1.0.31+)**:本文档对齐开源 dws 实际暴露的 9 个子命令:`commit / delete / download / info / list / list-spaces / mkdir / upload / upload-info`。 +> - 单步上传:`drive upload` 一条命令自动完成三步(推荐) +> - 手动三步:`drive upload-info` → 客户端 HTTP PUT → `drive commit`(适合自定义流式上传) +> - `list-spaces` 和 `delete` 由企业服务发现 envelope 注册(list_spaces / delete_document toolOverrides),不同企业 MCP gateway 暴露情况可能不一致,调用前可用 `--help` / `--dry-run` 验证。 + + +## 查询命令帮助 + +当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 + +```bash +# 查看 drive 下所有子命令 +dws drive --help + +# 查看具体命令的完整参数说明 +dws drive list --help +dws drive upload-info --help +dws drive download --help +``` + +规则: +- 参数名不确定时 → 先 `--help`,再调用 +- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 +- 不确定某个功能是否存在时 → `dws drive --help` 查看命令列表 + +## 命令总览 + +### 列出钉盘空间 + +``` +Usage: + dws drive list-spaces [flags] +Example: + dws drive list-spaces --format json + dws drive list-spaces --space-type org --max 50 --format json +Flags: + --cursor string 分页游标 (从上次结果的 nextToken 获取) + --max string 每页数量 + --space-type string 空间类型过滤 +``` + +> 适用场景:复制/移动文件到「我的文件」或团队空间根目录时取 `rootFolderId`;或者枚举用户可访问的所有 space。 +> 由 envelope toolOverrides (`drive.list_spaces`) 注册,部分企业 MCP gateway 可能未开通。 + +### 获取文件/文件夹列表 + +``` +Usage: + dws drive list [flags] +Example: + dws drive list --max 20 + dws drive list --max 20 --parent-id --order-by name --order asc +Flags: + --max int 每页返回数量 (默认 20,最大 50),别名: --limit + --next-token string 分页游标,首次不传 (可选) + --order string 排序方向: asc|desc,默认 desc (可选) + --order-by string 排序字段: createTime|modifyTime|name (可选) + --parent-id string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选) + --space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选) + --thumbnail 是否返回缩略图信息 (可选) +``` + +### 获取文件元数据信息 + +``` +Usage: + dws drive info [flags] +Example: + dws drive info --file-id +Flags: + --file-id string 节点 ID (dentryUuid) (必填) + --space-id string 节点所属空间 ID (可选) +``` + +### 下载文件到本地 + +下载流程一步到位:获取下载 URL → HTTP GET 下载文件二进制内容到本地。 + +``` +Usage: + dws drive download [flags] +Example: + dws drive download --file-id --output ./report.pdf + dws drive download --file-id --output ~/downloads/ +Flags: + --file-id string 文件 ID (dentryUuid) (必填) + --output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断 + --space-id string 文件所属空间 ID (可选) +``` + +> **注意**:`--output` 是必填参数,不传会报错。 + +### 创建文件夹 + +``` +Usage: + dws drive mkdir [flags] +Example: + dws drive mkdir --name "项目资料" + dws drive mkdir --name "子目录" --parent-id +Flags: + --name string 文件夹名称,最长 50 字符 (必填) + --parent-id string 父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选) + --space-id string 目标空间 ID,不传则使用「我的文件」 (可选) +``` + +### 一键上传 (三步合成) + +``` +Usage: + dws drive upload [flags] +Example: + dws drive upload --file ./report.pdf + dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx" + dws drive upload --file ./data.xlsx --folder +Flags: + --file string 本地文件路径 (必填) + --file-name string 文件显示名称 (默认使用本地文件名) + --folder string 父节点 ID (dentryUuid),不传则上传到空间根目录 + --space-id string 目标空间 ID,不传则使用「我的文件」 + --mime-type string 文件 MIME 类型,不传则自动推断 +``` + +> 客户端胶水命令:内部自动完成 `upload-info` → HTTP PUT 到 OSS → `commit_upload` 三步。 +> 优先用 `upload`;只在需要自己控制 HTTP PUT 时才走两步流程。 +> `--folder` 接受 `dentryUuid`(UUID 格式),禁止传纯数字 `dentryId`。 + +### 删除文件/文件夹到回收站 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws drive delete [flags] +Example: + dws drive delete --node --yes --format json +Flags: + --node string 文件或文件夹 nodeId(dentryUuid 格式,必填) + +Global Flags: + --yes 跳过二次确认;自动化脚本里不要默认带上 +``` + +> 由 envelope toolOverrides 注册(`drive.delete_document` 路由到 doc MCP 服务);不同企业 MCP gateway 可能不暴露,调用前用 `--help` 验证。 +> 软删除(进回收站),但仍需向用户明确确认;UI 侧从回收站还原即可。 + +## 意图判断 + +用户说"我的文件/钉盘/网盘/云盘" → `list` +用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `list-spaces` +用户说"文件详情/文件信息" → `info` +用户说"下载文件" → `download` +用户说"新建文件夹/创建目录" → `mkdir` +用户说"上传文件/传文件到钉盘" → `upload`(必须使用此命令,自动完成三步流程) +用户说"复制文件/移动文件/搬到/移到" → 使用 `dws doc copy`/`dws doc move`(详见下方「复制/移动钉盘文件」工作流) +用户说"删除文件/删除文件夹/移到回收站" → `delete`(危险操作,需确认) + +关键区分: drive(钉盘文件管理) vs doc(文档内容读写) + +**drive vs doc upload**: 用户提到"钉盘/网盘/我的文件"→ 用 `drive upload-info` + `drive commit` 两步流程;提到"知识库/文档空间/workspace"→ `doc upload` + +**钉盘文件复制/移动**: drive 本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现(详见下方工作流) + +## 核心工作流 + +```bash +# 1. 浏览「我的文件」根目录 +dws drive list --max 20 --format json + +# 2. 进入子目录 — 提取 dentryUuid 作为 parent-id +dws drive list --max 20 --parent-id --format json + +# 3. 查看文件元数据 +dws drive info --file-id --format json + +# 4. 下载文件到本地 +dws drive download --file-id --output /tmp/ --format json + +# 5. 创建文件夹 +dws drive mkdir --name "项目资料" --format json + +# 6. 上传文件(必须使用 upload 命令,禁止手动分步操作) + +# 7. 删除文件/文件夹到回收站(危险操作:必须先向用户确认,用户同意后才加 --yes 执行) +# 正确流程:1.向用户展示"即将删除「文件名」到回收站" → 2.等用户确认 → 3.执行下面命令 +dws drive delete --node --yes --format json +``` + +## 复制/移动钉盘文件 + +钉盘本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现。 + +> **注意:字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`(UUID 格式,如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)**作为 `--node` 和 `--folder` 的参数值。**禁止使用 `dentryId`(数字格式,如 `220335325118`),传入数字格式会导致命令失败。** + +> **注意**:钉盘场域下,仅支持将文件复制/移动到文件夹下,不支持文档下嵌套文档。 + +### 目标位置参数规则 + +| 目标位置 | 参数传递方式 | 前置步骤 | +|---------|-----------|---------| +| 未指定目标(默认) | `--folder ` | 先 `dws drive list-spaces` 取「我的文件」的 `rootFolderId` | +| 知识库空间根目录 | `--workspace ` | 无需额外步骤,直接传入 workspaceId | +| 钉盘 space 根目录 | `--folder ` | 先 `dws drive list-spaces` 取目标 space 的 `rootFolderId` | +| 钉盘 space 下的子文件夹 | `--folder ` | 先 `dws drive list --space-id ` 逐层浏览,获取目标文件夹的 `fileId`(dentryUuid 格式) | + +### 工作流示例 + +```bash +# ── 场景 默认: 用户未指定目标位置 → 复制/移动到「我的文件」根目录 ── +# 1. 获取源文件 dentryUuid +dws drive list --space-id --format json +# 2. 获取「我的文件」个人空间的 rootFolderId +dws drive list-spaces --format json +# 3. 用「我的文件」的 rootFolderId 作为 --folder +dws doc copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json + +# ── 场景 A: 复制钉盘文件到知识库空间根目录 ── +# 1. 获取源文件 dentryUuid +dws drive list --space-id --format json +# 2. 直接传 workspaceId 即可 +dws doc copy --node <源文件dentryUuid> --workspace --format json + +# ── 场景 B: 移动钉盘文件到另一个钉盘 space 根目录 ── +# 1. 获取源文件 dentryUuid +dws drive list --space-id --format json +# 2. 获取目标 space 的 rootFolderId +dws drive list-spaces --format json +# 3. 用 rootFolderId 作为 --folder(不需要传 --workspace) +dws doc move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json + +# ── 场景 C: 复制钉盘文件到钉盘 space 下的子文件夹 ── +# 1. 获取源文件 dentryUuid +dws drive list --space-id --format json +# 2. 浏览目标 space 找到目标文件夹的 fileId(dentryUuid 格式) +dws drive list --space-id --format json +# 若目标在更深层级,继续用 --parent-id 逐层浏览 +dws drive list --space-id --parent-id <父文件夹dentryUuid> --format json +# 3. 用目标文件夹的 fileId 作为 --folder +dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json +``` + +## 上下文传递表 + + +| 操作 | 从返回中提取 | 用于 | +| ------------- | ---------------------------- | -------------------------------------------------------- | +| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / list 的 --file-id 或 --parent-id;delete 的 --node;`doc copy/move` 的 --node 或 --folder | +| `list` | `spaceId` | info / download / mkdir / commit 的 --space-id | +| `list-spaces` | `rootFolderId` | `doc copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) | +| `list-spaces` | `spaceId` | list / info / download / mkdir / upload 的 --space-id | +| `mkdir` | `fileId`(UUID 格式) | list 的 --parent-id | + +> **重要**:`drive list` 返回结果中同时包含 `dentryId` 和 `fileId` 两个字段。`info / download` 等需要 `--file-id` 的命令,以及 `delete` 的 `--node` 参数,必须使用 `fileId`(即 dentryUuid),**不要使用** `dentryId`。 + + +## 注意事项 + +- 不传 `--space-id` 时默认使用「我的文件」空间 +- 不传 `--parent-id` 时默认操作空间根目录 +- `--parent-id` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录;`dentryId` 只用于 `chat message send --dentry-id` +- `--order-by` 支持: `createTime`、`modifyTime`、`name` +- **上传文件首选 `dws drive upload` 单步命令**(v1.0.31 起开源 dws 提供胶水命令);如需自定义流式上传可走 `upload-info` + `commit` 两步 +- `--file-name` 必须包含扩展名(如 `report.pdf`) + +## 自动化脚本 + + +| 脚本 | 场景 | 用法 | +| ------------------------------------------------------ | ----------- | ------------------------------------- | +| [drive_tree_list.py](../scripts/drive_tree_list.py) | 递归列出钉盘目录树结构 | `python drive_tree_list.py --depth 2` | + + +## 相关产品 + +- `dingtalk-doc` (`references/doc.md`) — 文档内容读写/知识库空间,不是文件存储 +- `dingtalk-chat` (`references/chat.md`) — 上传文件到 drive 后可通过 Markdown 语法发送图片/文件消息 diff --git a/skills/multi/dingtalk-drive/scripts/drive_tree_list.py b/skills/multi/dingtalk-drive/scripts/drive_tree_list.py new file mode 100644 index 00000000..a7e3439b --- /dev/null +++ b/skills/multi/dingtalk-drive/scripts/drive_tree_list.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +递归列出钉盘目录树结构(可指定深度) + +用法: + python drive_tree_list.py # 列出根目录 + python drive_tree_list.py --depth 2 # 递归 2 层 + python drive_tree_list.py --parent-id # 指定目录 + python drive_tree_list.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def list_dir( + parent_id: str = '', dry_run: bool = False, +) -> list: + cmd_args = [ + 'drive', 'list', '--max', '50', '--format', 'json', + ] + if parent_id: + cmd_args.extend(['--parent-id', parent_id]) + data = run_dws(cmd_args, dry_run=dry_run) + if not data: + return [] + if isinstance(data, list): + return data + if isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + return inner.get('items', inner.get('dentryList', [])) + if isinstance(inner, list): + return inner + return [] + + +def print_tree( + items: list, depth: int, max_depth: int, + prefix: str = '', dry_run: bool = False, +): + for i, item in enumerate(items): + is_last = (i == len(items) - 1) + connector = '└── ' if is_last else '├── ' + name = item.get('name') or item.get('fileName', '?') + item_type = item.get('type') or item.get('dentryType', '') + is_dir = str(item_type).lower() in ( + 'folder', 'directory', '1', 'FOLDER' + ) + icon = '📁' if is_dir else '📄' + size_str = '' + size = item.get('size') or item.get('fileSize') + if size and not is_dir: + size = int(size) + if size > 1024 * 1024: + size_str = f" ({size / 1024 / 1024:.1f}MB)" + elif size > 1024: + size_str = f" ({size / 1024:.1f}KB)" + else: + size_str = f" ({size}B)" + + print(f"{prefix}{connector}{icon} {name}{size_str}") + + if is_dir and depth < max_depth: + child_prefix = prefix + (' ' if is_last else '│ ') + dentry_id = (item.get('dentryUuid') + or item.get('id', '')) + if dentry_id: + children = list_dir(dentry_id, dry_run=dry_run) + print_tree( + children, depth + 1, max_depth, + child_prefix, dry_run, + ) + + +def main(): + parser = argparse.ArgumentParser( + description='递归列出钉盘目录树' + ) + parser.add_argument( + '--parent-id', default='', help='起始目录 ID' + ) + parser.add_argument( + '--depth', type=int, default=1, + help='递归深度 (默认 1, 最大 5)', + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + args.depth = min(args.depth, 5) + + root_name = args.parent_id or '我的文件' + print(f"📁 {root_name}") + + items = list_dir(args.parent_id, dry_run=args.dry_run) + if args.dry_run: + return + if not items: + print(' (空目录)') + return + + print_tree(items, 0, args.depth, '', args.dry_run) + print(f"\n共 {len(items)} 个项目 (根目录)") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-live/SKILL.md b/skills/multi/dingtalk-live/SKILL.md new file mode 100644 index 00000000..cbb7f0d5 --- /dev/null +++ b/skills/multi/dingtalk-live/SKILL.md @@ -0,0 +1,30 @@ +--- +name: dingtalk-live +description: 钉钉直播。Use when 用户说 直播/我的直播/直播列表。命令前缀:dws live。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉直播 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[live.md](references/live.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "我的直播 / 直播列表" | `dws live stream list` | diff --git a/skills/multi/dingtalk-live/references/live.md b/skills/multi/dingtalk-live/references/live.md new file mode 100644 index 00000000..829d4b83 --- /dev/null +++ b/skills/multi/dingtalk-live/references/live.md @@ -0,0 +1,13 @@ +# live — 直播 + +## 查看我的直播列表 +``` +Usage: + dws live stream list [flags] +Example: + dws live stream list --format json +``` + +## 意图判断 + +- 用户说"直播 / 我的直播" → `live stream list` diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md new file mode 100644 index 00000000..4c025f35 --- /dev/null +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -0,0 +1,44 @@ +--- +name: dingtalk-mail +description: 钉钉邮箱。Use when 用户说 发邮件/查邮件/回邮件/转发邮件/未读邮件/邮件搜索。Distinct from dingtalk-chat(钉钉消息)、dingtalk-ding(紧急通知)。命令前缀:dws mail。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉邮箱 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[mail.md](references/mail.md)。复杂搜索、附件、批量处理、草稿等多步邮件场景参考:[09-mail.md](references/09-mail.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "发邮件给 a@b.com" | `dws mail message send --from <自己邮箱> --to a@b.com --subject "<标题>" --body "<正文>"` | +| "今天未读邮件" | `python scripts/mail_unread_summary.py` | +| "带抄送发送" | `python scripts/mail_send_with_cc.py --to a@b.com --cc c@d.com --subject "<标题>" --body "<正文>"` | + +## 评测高频硬约束 + +- 用户要"完整内容/看看这封邮件/正文"时,`message search` 命中后必须继续调用 `dws mail message get --email <邮箱> --id --format json`;不要只列候选后停下。 +- 搜到多封邮件时,若用户给了明确主题、附件名、发件人或时间线索,先选最匹配的一封执行 `message get`;只有同等候选无法判断时才询问用户。 +- 写入类操作(发送)按安全策略确认;只读查看、搜索不需要确认。 +- 所有 `dws mail` 命令加 `--format json`,并复用同一封邮件的 `messageId`,不要重新搜索导致目标漂移。 + +## 跨产品协作 + +- 收件人是人名 → 先用 `dingtalk-contact` 取 `orgAuthEmail` +- 钉钉内消息 → 切到 `dingtalk-chat` diff --git a/skills/multi/dingtalk-mail/references/09-mail.md b/skills/multi/dingtalk-mail/references/09-mail.md new file mode 100644 index 00000000..952ca887 --- /dev/null +++ b/skills/multi/dingtalk-mail/references/09-mail.md @@ -0,0 +1,35 @@ +# 邮件 + +> **SKILL.md** 中 #9 内联 4 条 **lite**:`mail-list-mailbox`、`mail-search`、`mail-send`、`mail-reply-forward`,见 `dws-shared/references/best_practices/_common/lite-recipes.md`。下列 recipe、专用规则与消歧请在命中 #9 且**超出**上述 lite 时阅读本文。 +> 产品命令见 [mail.md](./mail.md)。通用批量/并行见 `dws-shared/references/best_practices/_common/conventions.md`。 + +> **能力范围**:当前 `dws mail` 只实现 `mailbox list` 与 `message search / get / send` 四条命令;其余 `reply` / `reply-all` / `forward` / `batch-*` / `draft *` / `folder *` / `attachment *` / `tag *` / `thread *` / `user search` 等命令**均未实现**,禁止编造。上面的 `mail-reply-forward` lite 触发场景("回复邮件 / 转发邮件")需要回退到引导用户在钉钉客户端手动操作。 + +## 专用规则(#9 非 lite 步骤必守) + +- **KQL 语法强制**:邮件搜索的查询条件**只能**通过 `--query` 参数以 KQL 语法传入(如 `subject:周报`),**禁止臆造** `--subject`、`--sender`、`--from-address` 等不存在的 flag。详见 [mail.md](./mail.md) 中 KQL 查询字段说明。 +- **邮箱地址前置**:所有邮件命令需要 `--email` 或 `--from` 参数,执行前**必须**先通过 `mail mailbox list` 获取当前用户邮箱(返回字段 `emailAccounts`),禁止猜测邮箱地址。 +- **查找他人邮箱**:需要获取某人邮箱地址时,**不要用 `mailbox list`**(只返回自己的),必须走两路并发查询流程(见 [mail.md](./mail.md) 中「查找他人邮箱地址」章节)。 +- **未实现命令禁止编造**:回复 / 转发 / 批量移动 / 批量删除 / 草稿 / 文件夹 / 附件 / 标签 / 会话线程 / mail user search 等命令当前都没实现,遇到相关诉求要么走 search/get/send 能覆盖的子集,要么向用户说明并引导到钉钉客户端。 + +## 与其他场景消歧 + +- **"给某人发邮件"**(只知姓名不知邮箱)→ 先走「查找他人邮箱地址」两路并发,再 `mail-send`。 +- **"找某人邮箱"**(终点是获取邮箱地址)→ 两路并发查询,不走 `mail-search`。 +- **"搜某人发的邮件"**(终点是邮件内容)→ `mail-search`,KQL 用 `from:xxx`。 +- **"催+邮件"** → `mail-send` 发催促邮件,不是 #1 消息。 +- **"邮件+待办"** → 先 `mail-search` 找邮件内容,再走 #2 创建待办。 +- **"回复 / 转发 / 删除 / 移动 / 草稿 / 附件 / 标签 / 会话线程"** → 当前 CLI 不支持,引导用户在钉钉客户端处理,不要编造命令。 + +## Recipe 速查(本表步骤,非 SKILL lite) + +| Recipe | 步骤 | +|--------|------| +| `mail-get` | `mail message get --email <邮箱> --id ` → 查看邮件完整内容(含 `message.markdownBody` 正文) | + +## Full / 多步组合 + +| Recipe | 行动指南(固定路线) | +|--------|---------------------| +| send-to-person-by-name | 1. `mail mailbox list` → 从 `emailAccounts` 取发件邮箱
2. 走「查找他人邮箱地址」两路并发查询获取收件人邮箱(见 [mail.md](./mail.md))
3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --body "<内容>"` | +| search-then-read | 1. `mail mailbox list` → 取邮箱
2. `mail message search --email <邮箱> --query "" --size 20` → 取 `messageId`
3. 对感兴趣的一封执行 `mail message get --email <邮箱> --id ` 读取正文 | diff --git a/skills/multi/dingtalk-mail/references/mail.md b/skills/multi/dingtalk-mail/references/mail.md new file mode 100644 index 00000000..affa5e0b --- /dev/null +++ b/skills/multi/dingtalk-mail/references/mail.md @@ -0,0 +1,230 @@ +# 邮箱 (mail) 命令参考 + +## 命令速查目录 + +| 命令 | 功能简述 | +|------|----------| +| `dws mail mailbox list` | 查询**当前用户自己**的可用邮箱列表 | +| `dws mail message search` | 搜索邮件(KQL 语法,按主题/发件人/日期等) | +| `dws mail message get` | 查看邮件完整内容(含正文) | +| `dws mail message send` | 发送邮件 | + +> **查找他人邮箱**(如「获取严龙的邮箱」)→ **不要用 `mailbox list`**,应走两路并发查询,详见「查找他人邮箱地址」章节。 + +> **范围说明:** 当前 `dws mail` 只实现了 `mailbox list` 与 `message search / get / send` 四条命令。诸如 `reply` / `reply-all` / `forward` / `batch-move` / `batch-delete` / `draft *` / `folder list` / `attachment list / download` / `tag list` / `thread get` / `mail user search` 等命令**目前尚未实现**,禁止编造这些命令——遇到 LLM 误生成时立即报错或回退到 ask_human。 + +--- + +## 默认邮箱选择规则(重要) + +所有 mail 相关命令,**除非用户明确要求使用个人邮箱,否则一律默认使用企业邮箱**。 + +**适用范围:** 任何需要传入 `--email` / `--from` 参数的 mail 子命令一律适用。 + +**默认选择策略:** + +1. 调用 `dws mail mailbox list --format json` 获取当前用户的所有邮箱。 +2. 从返回的 `emailAccounts` 中**优先选择企业邮箱**(账号 `type` 为 `ORGANIZATION`、域名非 `@dingtalk.com` 的邮箱),将其作为 `--email` / `--from` 的默认值。 +3. 仅当用户在指令中**明确指定**「用我的个人邮箱」「用 dingtalk.com 邮箱」「用我的私人邮箱」等表述时,才选择个人邮箱(`type` 为 `PERSONAL`,通常是 `@dingtalk.com` 域名)。 +4. 若用户同时拥有多个企业邮箱(如分属多家公司),优先选择与当前会话上下文匹配的企业邮箱;若仍无法判断,向用户确认后再操作。 +5. 若用户**仅拥有个人邮箱**(无企业邮箱),可直接使用个人邮箱。 + +**触发个人邮箱的关键词举例:** 「我的个人邮箱」「私人邮箱」「dingtalk.com 邮箱」「@dingtalk 的邮箱」「我的 personal 邮箱」。 + +> 该规则覆盖文档后续所有命令示例:示例中虽以 `user@company.com` 等占位邮箱书写,实际执行时**必须按上述策略动态选择企业邮箱**,不要直接照抄示例中的邮箱字面量,更不要默认使用 `@dingtalk.com` 个人邮箱。 + +--- + +## 命令总览 + +### 查询可用邮箱地址 +> **注意:** 仅返回当前登录用户**自己的**邮箱列表,不能用于查找他人邮箱。查找他人邮箱请使用两路并发流程(见"查找他人邮箱地址"章节)。 +``` +Usage: + dws mail mailbox list [flags] +Example: + dws mail mailbox list +``` + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `emailAccounts` | `List[]` | 邮箱列表,每条包含 `email` / `type` / `orgName`;type 取值 `PERSONAL`(个人邮箱)/ `ORGANIZATION`(企业邮箱) | + +### 查找他人邮箱地址(通讯录查人) + +> **这不是 `mailbox list`。** 当需要获取**某人**的邮箱地址时,必须走以下两路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。 + +**触发场景:** 用户说「获取/查找/得到 某人的邮箱地址」、「给某人发邮件」、「某人发给我的邮件」等任何涉及按姓名找邮箱的场景。 + +**两路并发查询流程:** + +```bash +# 同时发起以下两路,取最先返回有效邮箱的结果 +# 路径 1:aisearch + contact user get +dws aisearch person --keyword "姓名" --dimension name --format json +# → 取 userId,再执行: +dws contact user get --ids --format json +# → 提取 orgAuthEmail 字段 + +# 路径 2:contact user search +dws contact user search --query "姓名" --format json +# → 提取用户邮箱字段 +``` + +若两路均无有效邮箱,必须 `ask_human` 请用户手动提供,**严禁臆测**。 + +### 搜索邮件 (KQL 语法) +``` +Usage: + dws mail message search [flags] +Example: + dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20 + dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10 +Flags: + --cursor string 分页游标 (首页留空) + --email string 搜索目标邮箱地址 (必填) + --query string KQL 查询表达式 (必填) + --size string 每页返回数量 1-100 (默认 20) +``` + +KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to +常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除 + +### KQL 查询字段说明 + +| 字段 | 类型 | 说明 | 正确示例 | 错误示例 | +|------|------|------|----------|----------| +| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) | +| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) | +| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` | +| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) | +| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) | +| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` | +| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) | +| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) | +| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) | +| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:alice@company.com`、`from:"张 三"`、`from:"alice"` | `from:张 三`(含空格未加引号) | +| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:bob@company.com`、`to:"李 四"`、`to:"alice"` | `to:李 四`(含空格未加引号) | + +**组合查询说明:** +- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写) +- 括号用于分组:`(from:alice OR from:bob) AND folderId:2` +- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)` + +### message search 返回值说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) | +| `total` | `int32` | 符合条件的总邮件数 | +| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 | + +**翻页示例:** +```bash +# 第一页 +dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json +# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止) +dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor --format json +``` + +### 查看邮件完整内容 +``` +Usage: + dws mail message get [flags] +Example: + dws mail message get --email user@company.com --id +Flags: + --email string 邮件所属邮箱地址 (必填) + --id string 邮件 ID (必填) +``` + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文(`markdownBody`)等 | + +### 发送邮件 +``` +Usage: + dws mail message send [flags] +Example: + dws mail message send --from user@company.com --to colleague@company.com \ + --subject "周报" --body "本周完成任务A和任务B" +Flags: + --body string 邮件正文 (必填) + --cc string 抄送人列表,逗号分隔 (可选) + --from string 发件人邮箱 (必填) + --subject string 邮件标题 (必填) + --to string 收件人列表,逗号分隔 (必填) +``` + +> **附件能力**:当前 CLI 不支持通过 `dws mail message send` 直接附加附件或内联图片;如用户需要发带附件的邮件,先告知用户该限制,再让用户在钉钉客户端手动发送,**不要编造 `--attachment` / `--inline-attachment` 等参数**。 + +## 通用错误说明 + +以下错误适用于所有 mail 命令。 + +| 错误标识 | 含义 | 处理建议 | +|----------|------|----------| +| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 | + +## 意图判断 + +用户说"我的邮箱/邮箱地址" → `mailbox list`(**仅限查询自己的邮箱,不能查他人**) +用户说"获取/查找/得到 某人的邮箱地址" → **不是 `mailbox list`**,走两路并发查询流程(见「查找他人邮箱地址」章节) +用户说"找邮件/搜邮件/查邮件" → `message search` +用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get` +用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send` +用户说"给(某人名字)发邮件" / "查询某人发给我的邮件" / "查询发给某人的邮件" → + **第一步**:并发同时发起以下两路查询,取最先返回有效邮箱的结果;若两路均无,ask_human: + 1. `aisearch person --keyword <姓名>` → `contact user get --ids `,提取 `orgAuthEmail` + 2. `contact user search --query <姓名>`,提取用户邮箱字段 + **第二步**:用获得的目标邮箱拼入 KQL(如 `from:` 或 `to:`)执行 `message search`,或用于 `message send` +用户说"发带附件的邮件" → 当前 CLI 不支持附件参数,告知用户限制并建议在钉钉客户端手动操作 +用户说"回复邮件 / 转发邮件 / 批量移动邮件 / 删除邮件 / 草稿管理 / 列文件夹 / 列标签 / 下载附件 / 查看会话线程" → 当前 CLI 未实现这些命令,不要编造;告知用户在钉钉客户端处理 + +## 严格禁止 (NEVER DO) +- 明确禁止猜测、假设、推断发件人和收件人邮箱 +- 无法获取邮箱时,强引导 ask_human,由用户确认,不要通过假设或其他方式继续执行 +- **严禁在用户未明确指定使用个人邮箱时,默认选择 `@dingtalk.com` 个人邮箱作为 `--email` / `--from`**;默认必须从 `mailbox list` 中挑选企业邮箱(`type=ORGANIZATION`) +- **严禁编造不存在的命令**:当前 `dws mail` 顶层只有 `mailbox` 与 `message` 两个子命令,`message` 只支持 `search / get / send`;其余 `reply` / `reply-all` / `forward` / `batch-*` / `draft *` / `folder *` / `attachment *` / `tag *` / `thread *` / `user search` 等命令**均未实现**,遇到时报错而不是凭空生成 + +## 核心工作流 + +```bash +# 1. 查看可用邮箱 — 提取邮箱地址 +dws mail mailbox list --format json + +# 2. 搜索邮件 — 提取 messageId +dws mail message search --email user@company.com \ + --query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json + +# 3. 查看邮件详情 +dws mail message get --email user@company.com --id --format json + +# 4. 发送邮件 +dws mail message send --from user@company.com --to colleague@company.com \ + --subject "周报" --body "本周完成…" --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `mailbox list` | `emailAccounts[].email` | message search/get/send 的 --email/--from | +| `message search` | `messageId`(即返回的 `messages[].id`)| message get 的 --id | +| `aisearch person` → `contact user get` / `contact user search` | 用户邮箱 (orgAuthEmail / email) | message send 的 --to/--cc(两路并发,取先到结果) | + +## 注意事项 + +- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。**默认一律选择企业邮箱**(除非用户明确指定使用个人邮箱);若有多个企业邮箱可选,优先匹配用户当前所在企业的那一个;仍无法判断时向用户确认后再操作。详见文档顶部「默认邮箱选择规则」章节 +- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容 +- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号 +- `--cc` 抄送人支持多人,逗号分隔 +- 收件人邮箱获取:用户只知道同事名字时,**并发**同时执行以下两路查询,取最先返回有效邮箱的结果,无需等待另一路完成: + 1. `dws aisearch person --keyword "名字" --dimension name` → `dws contact user get --ids `,提取 `orgAuthEmail` + 2. `dws contact user search --query "名字"`,提取用户邮箱字段 + 若两路均无有效邮箱,必须 ask_human 请用户手动提供收件人邮箱,严禁臆测和假设 diff --git a/skills/multi/dingtalk-mail/scripts/mail_send_with_cc.py b/skills/multi/dingtalk-mail/scripts/mail_send_with_cc.py new file mode 100644 index 00000000..207f64f6 --- /dev/null +++ b/skills/multi/dingtalk-mail/scripts/mail_send_with_cc.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +发送带抄送的邮件(自动获取发件地址、校验参数) + +用法: + python mail_send_with_cc.py \ + --to colleague@company.com \ + --cc boss@company.com,team@company.com \ + --subject "周报" \ + --body "本周完成任务A和任务B" + + python mail_send_with_cc.py --dry-run \ + --to a@b.com --subject "test" --body "hello" +""" + +import sys +import json +import subprocess +import re +import argparse +from typing import List, Any, Optional + +EMAIL_PATTERN = re.compile( + r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$' +) + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def validate_emails(emails_str: str) -> bool: + for email in emails_str.split(','): + email = email.strip() + if not EMAIL_PATTERN.match(email): + print(f"错误:无效邮箱地址 '{email}'") + return False + return True + + +def get_my_email(dry_run: bool = False) -> Optional[str]: + data = run_dws([ + 'mail', 'mailbox', 'list', '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + if isinstance(data, list) and data: + item = data[0] + return (item.get('email') or item.get('address') + if isinstance(item, dict) else str(item)) + if isinstance(data, dict): + return data.get('email') or data.get('address') + return None + + +def main(): + parser = argparse.ArgumentParser( + description='发送带抄送的邮件' + ) + parser.add_argument('--to', required=True, help='收件人') + parser.add_argument('--cc', default='', help='抄送人') + parser.add_argument('--subject', required=True, help='标题') + parser.add_argument('--body', required=True, help='正文') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + if not validate_emails(args.to): + sys.exit(1) + if args.cc and not validate_emails(args.cc): + sys.exit(1) + + print('📬 获取发件邮箱...') + from_email = get_my_email(dry_run=args.dry_run) + if not from_email and not args.dry_run: + print('错误:无法获取发件邮箱') + sys.exit(1) + + cmd_args = [ + 'mail', 'message', 'send', + '--from', from_email or '', + '--to', args.to, + '--subject', args.subject, + '--body', args.body, + '--format', 'json', + ] + if args.cc: + cmd_args.extend(['--cc', args.cc]) + + print('📤 发送邮件...') + result = run_dws(cmd_args, dry_run=args.dry_run) + if result: + print(f" ✓ 邮件已发送") + print(f" 收件人: {args.to}") + if args.cc: + print(f" 抄送: {args.cc}") + print(f" 主题: {args.subject}") + else: + print(' ✗ 发送失败') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-mail/scripts/mail_unread_summary.py b/skills/multi/dingtalk-mail/scripts/mail_unread_summary.py new file mode 100644 index 00000000..7005d787 --- /dev/null +++ b/skills/multi/dingtalk-mail/scripts/mail_unread_summary.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +查询今天未读邮件并汇总(自动获取邮箱地址) + +用法: + python mail_unread_summary.py + python mail_unread_summary.py --size 30 + python mail_unread_summary.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timezone, timedelta +from typing import List, Any, Optional + +TZ = timezone(timedelta(hours=8)) + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def get_my_email(dry_run: bool = False) -> Optional[str]: + data = run_dws([ + 'mail', 'mailbox', 'list', '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return '' + if not data: + return None + if isinstance(data, list) and data: + item = data[0] + return (item.get('email') or item.get('address') + if isinstance(item, dict) else str(item)) + if isinstance(data, dict): + return data.get('email') or data.get('address') + return None + + +def main(): + parser = argparse.ArgumentParser( + description='查询今天未读邮件' + ) + parser.add_argument( + '--size', type=int, default=20, help='返回数量' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print('📬 获取邮箱地址...') + email = get_my_email(dry_run=args.dry_run) + if not email and not args.dry_run: + print('错误:无法获取邮箱地址') + sys.exit(1) + + today = datetime.now(TZ).strftime('%Y-%m-%dT00:00:00Z') + kql = f'isRead:false AND date>{today}' + + print(f'🔍 搜索未读邮件...\n') + data = run_dws([ + 'mail', 'message', 'search', + '--email', email or '', + '--query', kql, + '--size', str(args.size), + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + return + if not data: + print('未查到邮件') + return + + messages = (data if isinstance(data, list) + else data.get('items', data.get('messages', []))) + + print(f"📧 今日未读邮件") + print('=' * 50) + if not messages: + print(' ✅ 收件箱清空,没有未读邮件!') + return + + for m in messages: + subj = m.get('subject', '(无主题)') + sender = m.get('from', {}) + sender_name = (sender.get('name') or sender.get('email', '未知') + if isinstance(sender, dict) else str(sender)) + print(f" 📩 {subj}") + print(f" 发件人: {sender_name}") + + print(f"\n合计: {len(messages)} 封未读邮件") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-minutes/SKILL.md b/skills/multi/dingtalk-minutes/SKILL.md new file mode 100644 index 00000000..1e797bd5 --- /dev/null +++ b/skills/multi/dingtalk-minutes/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dingtalk-minutes +description: 钉钉 AI 听记。Use when 用户说 听记/会议录音/会议纪要/AI摘要/转写/关键字/听记标题/会后待办提取/分享听记。Distinct from dingtalk-calendar(日程)、dingtalk-report(日报)、dingtalk-doc(普通文档)。命令前缀:dws minutes。URL `shanji.dingtalk.com` 一定路由到此 skill。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 AI 听记 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[minutes.md](references/minutes.md);剧本:[07-minutes.md](references/07-minutes.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "我的听记列表" | `dws minutes list mine [--query "<关键词>"] [--start ""] [--end ""]` | +| "查某段时间/最近/本周/上月的听记" | `dws minutes list all --start "" --end "" [--query "<关键词>"]` | +| "看一篇听记摘要" | `dws minutes get summary --id ` | +| "看转写 / 原文" | `dws minutes get transcription --id ` | +| "近期听记摘要合并" | `python scripts/minutes_recent_summary.py --max 5` | +| "提取会议待办" | `python scripts/minutes_extract_todos.py --id ` | +| "改听记标题" | `dws minutes update title --id --title "<新标题>"` | + +## 评测高频硬约束 + +- `shanji.dingtalk.com` URL 必须走 `dws minutes`,禁止用浏览器或 `read_file` 打开链接。自动提取 taskUuid 后调用 `get info/summary/transcription/todos`。 +- 用户给了时间线索(今天、本周、上周、上月、最近 N 天、某日期范围)时,必须自行计算 `--start` / `--end`,格式用 ISO-8601,如 `2026-05-11T00:00:00+08:00`。不要反问用户时间范围。 +- 未指定 mine/shared 时,检索型任务默认 `list all`;如果只查"我创建的"才用 `list mine`。 +- 不要全量拉取后本地过滤时间。时间范围和关键词能服务端过滤时必须放进同一条 `list all --start --end --query`。 +- 列表为空时按顺序兜底:同范围 `list all` → 去掉关键词但保留时间范围 → 明确告知无数据。禁止用模板或虚构听记内容继续生成纪要/周报。 +- 生成纪要、文档、待办、周报前,必须先完成 `list` → 选定真实 `taskUuid` → `get summary`;需要原文或行动项时继续 `get transcription` / `get todos`。前置数据没拿到就停止并说明卡点。 +- 所有 dws 命令带 `--format json`,不要用 shell 管道、重定向、`head`、`grep`、`jq`。 + +## 跨产品协作 + +- 提取的待办批量建任务 → 切到 `dingtalk-todo`(`scripts/todo_batch_create.py`) +- 摘要发给同事 → 切到 `dingtalk-chat` +- 日程 / 会议室 → 切到 `dingtalk-calendar` diff --git a/skills/multi/dingtalk-minutes/references/07-minutes.md b/skills/multi/dingtalk-minutes/references/07-minutes.md new file mode 100644 index 00000000..c04b8d20 --- /dev/null +++ b/skills/multi/dingtalk-minutes/references/07-minutes.md @@ -0,0 +1,11 @@ +# 听记与会后 + +> lite(`list-minutes`、`get-minutes-summary`、`get-minutes-transcription`)见本 skill 的 [SKILL.md](../SKILL.md)。full recipe 见下表。 +> 日程、订会议室、`schedule-meeting` 在 `dingtalk-calendar` skill 的 03-meeting.md。产品命令见 [minutes.md](./minutes.md)。 + +| Recipe | 行动指南(固定路线) | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| meeting-followup | 1. **提取待办优先**:`python scripts/minutes_extract_todos.py [--id ] [--max N]` → 获取行动项列表 无脚本时按「多源并行采集」(见 dws-shared 的 conventions.md)执行 2. 提取行动项 3. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` → `todo task create --title "<行动项>" --executors `(每条行动项;批量时通过 `dingtalk-todo` skill:`python scripts/todo_batch_create.py todos.json`,或循环 `dws todo task create`) 4. `chat message send --group --text "<通知内容>"` 通知已创建 | +| share-minutes | **拉摘要优先**:`python scripts/minutes_recent_summary.py [--max N] [--output summary.md]` → 获取近期听记摘要 备选手动:1. `minutes list mine` → 取 `taskUuid` 2. 用户选定 3. `minutes get summary --id ` → 取摘要 4. 单聊:`contact user search --query "<姓名>"` → 取 `openDingTalkId` → `chat message send --open-dingtalk-id --text "<摘要内容>"`(推荐);群聊:`--group --text "<摘要内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | +| browse-minutes | 1. `minutes list all --query "<关键词>" --max ` → 取全量 `taskUuid`(翻页直至无更多;无主题筛选用 `minutes list mine`) 2. **详情/元数据优先** `minutes get batch --ids `(仅 API 上限时拆多批);**摘要专项**且无 batch 字段时再用并行 `get summary` 或 `**python scripts/minutes_recent_summary.py --max `** 3. 汇总展示;**同一用户诉求下避免**「半截 list → 再 list 剩余」「拆 4 条一批 summary 连跑多轮」等无必要拆分 | +| minutes-detail | 1. 确定 `taskUuid`:用户已提供 → 直接用;未提供 → `minutes list mine --max 5` 让用户选 2. 并行拉取四维信息:`minutes get info --id ` `&` `minutes get summary --id ` `&` `minutes get keywords --id ` `&` `minutes get todos --id ` `& wait` 3. 整合输出:基础信息(标题、时间、参与人)→ AI 摘要 → 关键字 → 行动项/待办 4.(可选)用户要求看原文 → `minutes get transcription --id ` | diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md new file mode 100644 index 00000000..56b9893e --- /dev/null +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -0,0 +1,1808 @@ +# AI听记 (minutes) 命令参考 + +## 命令层级结构(防混淆,必须先读) + +minutes 模块的命令是**两级或三级结构**,不同层级之间不能混用、跳级或遗漏: + +> **默认 scope 规则**:`dws minutes list` 后**必须**跟 scope 子命令。若用户未明确指定查询范围,**一律默认补 `all`**(查询所有有权限的听记,覆盖面最广)。 + +``` +dws minutes +├── list # 二级:list 后必须跟 mine / shared / all +│ ├── mine [--query] [--start] [--end] [--max] [--next-token] +│ ├── shared [--query] [--start] [--end] [--max] [--next-token] +│ └── all [--query] [--start] [--end] [--max] [--next-token] +├── get # 二级:get 后必须跟子命令名 +│ ├── info --id +│ ├── summary --id +│ ├── transcription --id +│ ├── keywords --id +│ ├── todos --id +│ └── batch --ids +├── update # 二级:update 后必须跟子命令名 +│ ├── title --id --title "..." +│ └── summary --id --content "..." +├── speaker +│ └── replace --id --from "..." --to "..." +├── mind-graph +│ ├── create --id +│ └── status --id +├── hot-word add --words "..." +├── replace-text --id --search "..." --replace "..." +└── upload + ├── create --file-name "..." --file-size + ├── complete --session-id + └── cancel --session-id +``` + +> **能力范围说明**:当前 `dws minutes` 顶层不支持 `record`(录音控制 start/pause/resume/stop),`speaker` 下不存在 `summary` 子命令组(create/get),`get` 下不存在 `audio` 子命令。涉及"开始/暂停/结束录音"、"发言人段落总结"、"获取音频地址"等诉求时,**禁止编造命令**,应回退到引导用户在钉钉听记 App 内操作。 + +**高频错误命令 vs 正确命令对照(真实 badcase 提炼):** + +| 错误写法 | 错在哪 | 正确写法 | +|------------|--------|------------| +| `dws minutes info --id ` | `info` 不是顶层子命令,应在 `get` 下 | `dws minutes get info --id ` | +| `dws minutes summary --id ` | `summary` 不是顶层子命令,应在 `get` 下 | `dws minutes get summary --id ` | +| `dws minutes transcription --id ` | `transcription` 不是顶层子命令,应在 `get` 下 | `dws minutes get transcription --id ` | +| `dws minutes get --uuid ` | `get` 后缺少子命令(info/summary/transcription 等) | `dws minutes get summary --id ` | +| `dws minutes get --task-uuid ` | 同上,`get` 后缺子命令。注意:`--task-uuid` 在子命令层是合法别名,但 `get` 层级不认识它 | `dws minutes get info --id ` | +| `dws minutes detail --id ` | `detail` 不是合法子命令——**不存在此命令**。LLM 高频幻觉 | `dws minutes get info --id ` | +| `dws minutes list --start "2026-04-01"` | `list` 后缺少 scope(mine/shared/all),**缺省时默认补 `all`** | `dws minutes list all --start "2026-04-01"` | +| `dws minutes list all --start-time "..."` | 参数名是 `--start` 不是 `--start-time` | `dws minutes list all --start "2026-04-01"` | +| `dws minutes list all --end-time "..."` | 参数名是 `--end` 不是 `--end-time` | `dws minutes list all --end "2026-04-30"` | +| `dws minutes list --page-size 10` | `--page-size` 不存在,分页用 `--max`;且 `list` 后缺 scope | `dws minutes list mine --max 10` | +| `dws minutes list --date-range 2026-05-04 2026-05-10` | `--date-range` 不存在,时间范围用 `--start` + `--end` 两个参数;且 `list` 后缺 scope | `dws minutes list mine --start "2026-05-04T00:00:00+08:00" --end "2026-05-10T23:59:59+08:00"` | +| `dws minutes list mine --limit 10` | `--limit` 不存在(其他模块用 `--limit`,minutes 用 `--max`) | `dws minutes list mine --max 10` | +| `dws minutes upload create --json '{"fileName":...}'` | `--json` 不存在,cli 不接受 JSON 作为输入格式 | `dws minutes upload create --file-name "xxx.mp3" --file-size 61565431` | +| `dws minutes upload create -f json '{"fileName":...}'` | `-f json` / `--format json` 是**输出格式**控制,不是输入参数 | `dws minutes upload create --file-name "xxx.mp3" --file-size 61565431 --format json` | +| `dws minutes get transcription --id \| head -c 2000` | Windows 沙箱无 `head` 命令,**严禁使用 shell 管道截断** | `dws minutes get transcription --id --format json`(由 AI 在内存中截断处理) | +| `dws minutes get summary --id "https://shanji.dingtalk.com/app/transcribes/xxx"` | `--id` 只接受 taskUuid(hex 字符串),**不接受完整 URL**。AI 必须自动提取,不要让用户手动抠 | AI 自动从 URL 提取 taskUuid,再 `dws minutes get summary --id `(详见「URL → taskUuid 自动提取规则」) | +| `dws minutes get transcription --id "https://shanji.dingtalk.com/meeting/minutes?taskUuid=xxx"` | 同上,`--id` 不接受 URL,且这是 `?taskUuid=` 格式的 URL | AI 自动提取 `taskUuid=` 后的值,再 `dws minutes get transcription --id --format json` | +| `dws minutes get transcription --id ` 只调用一次就"总结整篇听记" | **单次调用最多返回 50 段**,不翻页就只能看到前 1/3~1/8 的内容 | **必须自动循环翻页**:检查返回中的 `nextToken`,非空则继续调用 `--next-token `,直到拉完(详见「转写接口分页机制」) | +| `dws minutes get summary --id A & dws minutes get summary --id B & wait` | Windows cmd 下 `&` 是命令分隔符不是 background,`wait` 不存在 | 逐条串行调用,或让 AI 在内存中合并结果;**严禁使用 shell 并行/管道/重定向** | +| `dws minutes get transcription --id --next-token ` 报 unknown flag | **不会报错**——`--next-token` 是 `get transcription` 的合法参数(见命令总览) | 确保写法正确:`dws minutes get transcription --id --next-token --format json` | +| `dws minutes get transcription --id --page-token ` | `--page-token` 不存在,正确参数名是 `--next-token` | `dws minutes get transcription --id --next-token ` | +| `dws report inbox --format json` | `inbox` 不是 report 的子命令(可用:create/detail/list/sent/stats/template) | `dws report list --start "2026-05-06T00:00:00+08:00" --format json` | +| `dws report list --format json`(不带 --start) | report list **必须**带 `--start` 参数,否则报 "flag --start is required" | `dws report list --start "2026-05-06T00:00:00+08:00" --end "2026-05-13T23:59:59+08:00" --format json` | + +> **参数别名说明**:`--id`、`--uuid`、`--task-uuid` 三者等价,均可使用。推荐统一用 `--id`,但 `--uuid` 和 `--task-uuid` 也是合法别名,不会报错。 + +**铁律:禁止编造 taskUuid(0512 P0 Golden Case 提炼)** + +用户未直接提供 taskUuid / URL 时,**必须**先调用 `dws minutes list mine`(或 `list all`)获取列表,从返回结果中按标题/参会人/时间匹配选出正确条目,再用其 taskUuid 调用后续命令。**绝不允许凭空编造 hex 字符串作为 --id 的值。** + +典型错误链路: +``` +用户: "帮我看一下上次和 Tony 开会的听记摘要" +错误: LLM 直接编造: dws minutes get summary --id "7632756964..." -- 这个 id 是捏造的 +正确: 先 dws minutes list mine → 从结果按标题/参会人匹配 → 再 get summary --id <真实id> +``` + +**意图→命令决策表(0512 P0 Golden Case 提炼):** + +| 用户意图关键词 | 对应命令 | 说明 | +|--------------|---------|------| +| 总结、摘要、重点、概述、主要内容、按模板整理 | `get summary` | 返回结构化摘要 | +| 逐字稿、全文、原文、完整记录、转写、录音文字 | `get transcription` | 返回完整转写原文 | +| "帮我看/总结某次会议"(未给 id) | 先 `list mine/all` | 按标题/时间匹配后再 get | +| "上上周/两周前的会议" | `list mine --start --end` | 需正确计算时间范围 | + +**多步流程错误传播规则(0512/0514 P0 Golden Case 提炼):** + +> 当执行 list → get summary/transcription → 套模板/写文档 等多步流程时: +> 1. 任何一步 dws 命令返回错误,**必须立即告知用户**卡在哪一步、具体错误信息 +> 2. **严禁**跳过失败步骤假装成功 +> 3. **严禁**用虚构数据填补失败步骤的空缺 +> 4. 如果是权限问题 (NoPermission),建议用户检查听记是否为自己创建或已被共享 +> 5. 最终交付物中需注明哪些数据成功获取、哪些被权限/参数阻断 +> 6. **严禁多步流程中途放弃**(0514 P0 新增):前置步骤(如 `contact user get-self` 获取 userId)成功后,**必须**继续执行后续步骤(如 `calendar event list`、`minutes list mine`)。不能只完成前置步骤就停下来,回复"我打算帮你查…"却不实际执行 + +**诉求偏移防护规则(0514 P1 Golden Case 提炼):** + +> 用户明确要求"基于听记内容做 XX"(如生成会议纪要、汇报材料、周报)时: +> 1. **必须先实际调用** `dws minutes list` → `get summary/transcription` 获取真实数据 +> 2. **严禁**跳过数据获取步骤直接"创建 skill"或"生成模板"——用户要的是**基于真实听记数据**的输出,不是空壳工具 +> 3. 如果数据获取失败,应告知用户具体原因,而非转向其他不相关的操作 +> +> **典型错误链路(0514 P1 bug 复现):** +> ``` +> 用户: "基于钉钉听记内容重新生成 Word 格式会议纪要" +> [错误] 模型创建了一个 skill 模板,但从未调用 dws minutes get summary/transcription 获取真实数据 +> [正确] 先 dws minutes list mine → 从结果选匹配条目 → get summary --id → 基于返回数据生成纪要 +> ``` + +**跨模块组合注意事项(周报生成等场景,0512/0513/0514 P0 Golden Case 提炼):** + +> - 周报生成等场景需并行拉取 minutes / report / calendar / todo / doc 多类数据 +> - **各模块参数名独立**,不可互相套用(如 doc 的参数不适用于 minutes) +> - **跨模块参数名交叉污染是 0514 最高频错误**(3/7 个 case 涉及):切换模块时必须重新确认该模块的参数名,不要从上一个模块"顺手带过来"。典型错误对:`--start-time`(calendar) vs `--start`(minutes/report)、`--task-uuid`(无) vs `--id`(minutes) +> - **禁止**使用管道命令 `| jq`,Windows/macOS 客户端不保证 jq 可用,应由 LLM 直接解析 JSON +> - report list sent **必须**带 `--start` 参数(格式 `2026-05-04T00:00:00+08:00`),否则报错 +> - `dws report inbox` **不存在**——report 可用子命令为 create/detail/list/sent/stats/template。LLM 高频误猜 `inbox`,需明确跳过 +> - report list 不传 `--start` 会直接报错 "flag --start is required",**不要让用户手动补**——AI 应自动推断最近 7 天范围填入 +> - doc create 参数名是 `--content`(非 `--markdown`) + +**钉钉听记 URL → taskUuid 自动提取规则(0513/0514 P0 Golden Case 提炼):** + +> **`--id` 参数只接受纯 taskUuid(hex 字符串),绝不接受完整 URL。** 但用户几乎总是直接粘贴完整 URL 过来,AI **必须自动完成 URL → taskUuid 的提取**,对用户完全透明——用户不需要知道"要抠出 hex 字符串"。 +> +> **严禁**: +> - 直接把完整 URL 传入 `--id`(会报错) +> - 要求用户自己从 URL 中手动提取 taskUuid(用户体验极差,这是 AI 应自动完成的工作) +> - 把 URL 中的 query parameter(如 `?taskUuid=xxx`)和 path segment(如 `/transcribes/xxx`)搞混 +> +> **已知的钉钉听记 URL 格式全表(AI 必须全部识别):** +> +> | URL 格式 | taskUuid 提取方式 | 示例 | +> |---------|-----------------|------| +> | `https://shanji.dingtalk.com/app/transcribes/` | 取 `/transcribes/` 后面的**整段路径** | `.../transcribes/7632756964323839...5f32` → `7632756964323839...5f32` | +> | `https://shanji.dingtalk.com/meeting/minutes?taskUuid=` | 取 `taskUuid=` 后面的 query parameter 值 | `...?taskUuid=7632756964323839...5f32` → `7632756964323839...5f32` | +> | `https://shanji.dingtalk.com/app/transcribes/?xxx=yyy` | 取 `/transcribes/` 和 `?` 之间的部分(忽略 query string) | `.../transcribes/763275...5f32?from=share` → `763275...5f32` | +> | 纯 hex 字符串(无 URL 前缀) | 直接使用,无需提取 | `7632756964323839...5f32` → 直接传 `--id` | +> +> **提取伪代码(AI 必须内化此逻辑):** +> ``` +> input = 用户提供的字符串(可能是 URL,也可能是纯 uuid) +> +> if input 包含 "/transcribes/": +> taskUuid = input 中 "/transcribes/" 之后、"?" 之前(如有)的部分 +> elif input 包含 "taskUuid=": +> taskUuid = input 中 "taskUuid=" 之后、"&" 之前(如有)的部分 +> elif input 匹配纯 hex 字符串模式 (只含 0-9a-f 且长度 > 20): +> taskUuid = input # 已经是纯 uuid +> else: +> 告知用户格式不识别,请提供听记链接或 taskUuid +> +> # 然后使用: dws minutes get summary --id --format json +> ``` +> +> **典型错误链路(0514 P0 bug 复现):** +> ``` +> 用户: "帮我看看这个听记 https://shanji.dingtalk.com/app/transcribes/7632756964323839..." +> +> [错误 1] dws minutes get summary --id "https://shanji.dingtalk.com/app/transcribes/763..." → 报错 invalid id +> [错误 2] "请你把 URL 中的 taskUuid 提取出来给我" → 不应该让用户手动做 +> [错误 3] AI 把整个 URL decode 当作 taskUuid → 格式不对 +> [正确] AI 自动提取 → dws minutes get summary --id "7632756964323839..." --format json +> ``` +> +> **多链接批量场景**:用户一次性粘贴多个 URL 时,逐一提取 taskUuid,按顺序串行调用对应的 get 命令。**严禁使用 shell `&` 并行或 `|` 管道**。 + +**权限错误处理策略(0513 P0 Golden Case 提炼):** + +> `get info` / `get summary` / `get transcription` / `get batch` 访问他人发起的听记时,可能报以下权限错误: +> +> | 错误信息 | 含义 | AI 应如何处理 | +> |---------|------|-------------| +> | `[AUTH_PERMISSION_DENIED] Permission denied` | 当前用户对该听记无权限(不是创建者,且未被共享) | 告知用户:"该听记由他人创建且未共享给你。请联系听记创建者在钉钉听记详情页将你加为共享者,然后再次尝试。" | +> | `[MCP_TOOL_ERROR] B_PERMISSION_NoPermission` | 同上,后端 dingOpenErrcode=14000011 | 同上处理 | +> | `[UNCLASSIFIED] user is not minutes creator` | batch 接口单条权限失败 | 告知用户哪几条 id 是自己的(可正常获取)、哪几条无权限,**不要整批放弃** | +> +> **关键原则**: +> - 权限报错 ≠ 命令写法有误——**不要**因为权限失败就去改参数名或换命令路径 +> - batch 调用中如果部分成功部分权限失败,应**展示成功的结果** + **列出失败的 id 和原因** +> - `list shared` 返回的听记是**已被共享给我的**,这些 id 后续调用 get 不应报权限错误 +> - `list all` 返回的所有 id 都是**当前有权限的**,可放心调用 + +**Shell 命令安全规则(0513 P0 Golden Case 强化):** + +> dws cli 运行在用户本地设备(Windows/macOS/Linux),**严禁使用任何 shell 特性**: +> +> | 禁止写法 | 原因 | 正确替代 | +> |---------|------|----------| +> | `dws ... \| head -20` | Windows cmd 无 `head` | 由 AI 在内存中截断 | +> | `dws ... \| grep "xxx"` | Windows cmd 无 `grep` | 由 AI 在返回 JSON 中筛选 | +> | `dws ... 2>&1 \| head` | 混合重定向+管道 | 直接读 dws 输出 | +> | `dws A & dws B & wait` | cmd 下 `&` 是分隔符不是 background | 逐条串行执行 | +> | `dws ... > output.txt` | 不确定用户 shell 是否支持 | 由 AI 直接处理返回结果 | +> +> **铁律:所有 dws 命令必须单条、独立、无管道地执行,AI 自行处理输出内容。** + +**Upload 模块错误诊断(0513 P0 Golden Case 提炼):** + +> `dws minutes upload create` 失败时的常见原因与处理: +> +> | 错误表现 | 真实原因 | 处理方式 | +> |---------|---------|---------| +> | `business error: success=false`(无详细信息) | 默认输出不含详细错误,需检查是否有并发会话 | 告知用户:"上传失败,可能原因包括:① 已有一个进行中的上传会话未完成/取消;② 文件名含特殊字符;③ 文件格式不支持。" | +> | `concurrent_limit: user already has an active upload session` | 前一次上传会话未结束(未 complete 也未 cancel) | 需要先 `dws minutes upload cancel --session-id ` 取消上一个会话。若 session-id 未知,建议用户在钉钉客户端手动取消或等待超时 | +> | `missing required flag(s): --session-id` | cancel 命令必须传 session-id | 从之前 upload create 的返回中提取 session-id;若丢失则暂无法取消 | +> +> **上传流程三步走**(AI 必须按此顺序执行,不可跳步): +> 1. `dws minutes upload create --file-name "xxx.mp3" --file-size <字节数> --format json` → 获取 `sessionId` + `uploadUrl` +> 2. 将文件通过 `uploadUrl` 直传(由客户端侧完成,AI 不介入) +> 3. `dws minutes upload complete --session-id --format json` → 确认上传完成 +> +> **注意**:`--file-size` 单位是**字节**(不是 KB/MB),AI 不要帮用户估算大小,应让用户确认文件实际字节数。 + +## 命令总览 + +### 查询我创建的听记列表 +``` +Usage: + dws minutes list mine [flags] +Example: + dws minutes list mine + dws minutes list mine --max 10 + dws minutes list mine --max 10 --next-token + dws minutes list mine --query "周会" +Flags: + --max float 查询的听记篇数 (默认 10) + --next-token string 分页 token (首页留空,后续填写前次返回的 nextToken) + --query string 关键字筛选 (可选) + --start string 开始时间 ISO-8601 (可选) + --end string 结束时间 ISO-8601 (可选) +``` + +查询我创建的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。 + +### 查询他人共享给我的听记列表 +``` +Usage: + dws minutes list shared [flags] +Example: + dws minutes list shared + dws minutes list shared --max 20 + dws minutes list shared --max 5 --next-token +Flags: + --max float 查询的听记篇数 (默认 10) + --next-token string 分页 token (首页留空,后续填写前次返回的 nextToken) + --query string 关键字筛选 (可选) + --start string 开始时间 ISO-8601 (可选) + --end string 结束时间 ISO-8601 (可选) +``` + +查询他人共享给我的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。 + +### 查询我有权限访问的所有听记列表 +``` +Usage: + dws minutes list all [flags] +Example: + dws minutes list all + dws minutes list all --max 20 + dws minutes list all --query "周会" --max 20 + dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00" + dws minutes list all --max 10 --next-token +Flags: + --end string 结束时间 ISO-8601 (可选) + --query string 关键字筛选 (可选) + --max float 查询的听记篇数 (默认 10) + --next-token string 分页 token (首页留空,后续填写前次返回的 nextToken) + --start string 开始时间 ISO-8601 (可选) +``` + +查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--next-token` 进行分页查询。 + +### 获取听记基础信息 +``` +Usage: + dws minutes get info [flags] +Example: + dws minutes get info --id +Flags: + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 +``` + +返回字段: 创建人、开始时间、截止时间、听记标题、听记访问链接URL + +### 获取听记 AI 摘要 +``` +Usage: + dws minutes get summary [flags] +Example: + dws minutes get summary --id +Flags: + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 +``` + +返回 Markdown 格式摘要,涵盖会议主题、核心结论、关键讨论点等 + +### 获取听记关键字列表 +``` +Usage: + dws minutes get keywords [flags] +Example: + dws minutes get keywords --id +Flags: + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 +``` + +### 获取听记语音转写原文 +``` +Usage: + dws minutes get transcription [flags] +Example: + dws minutes get transcription --id + dws minutes get transcription --id --direction 1 + dws minutes get transcription --id --next-token --format json +Flags: + --direction string 排序方向: 0=正序, 1=倒序 (默认 0) + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 + --next-token string 下一页的token 首次查询可空 后续查询需填写前次请求返回的nextToken +``` + +每条记录包含: 发言人信息、转写文本、对应时间戳 + +**关键:转写接口每次最多返回 50 段,必须自动翻页才能拿到完整原文!** + +> **这是最高频的 silent failure:** `get transcription` 单次调用**最多只返回 50 段**。一篇 30 分钟会议的转写通常有 150-400 段,如果不翻页就只能看到前 1/3 甚至 1/8 的内容。 +> +> **分页机制(必须掌握):** +> - 首次调用:`dws minutes get transcription --id --format json`(不传 `--next-token`) +> - 检查返回 JSON 中是否包含 `nextToken` 字段(非空字符串) +> - 如果有 `nextToken`:**必须立即发起下一次调用**,传入 `--next-token <上次返回的nextToken值>` +> - 循环直到返回中**不再包含 `nextToken`**(或 `nextToken` 为空),表示所有段落已拉取完毕 +> - **拼合所有页的段落**后,才算拿到了完整转写原文 +> +> **自动翻页伪代码(AI 必须内化此逻辑):** +> ``` +> all_paragraphs = [] +> next_token = "" # 首次为空 +> loop: +> if next_token == "": +> result = dws minutes get transcription --id --format json +> else: +> result = dws minutes get transcription --id --next-token --format json +> all_paragraphs.append(result.paragraphs) +> next_token = result.nextToken # 可能为空或不存在 +> if next_token 为空或不存在: +> break # 全部拉取完毕 +> # 此时 all_paragraphs 才是完整转写原文 +> ``` +> +> **典型错误(导致"总结整篇听记"只看到前 50 段的 P0 bug):** +> - [错误] 只调用一次 `get transcription`,看到返回了内容就以为拿全了 → 实际只有前 50 段 +> - [错误] 看到返回 JSON 里有 `nextToken` 字段但不知道这是什么、不知道要传 `--next-token` → 默默丢弃了后续页 +> - [错误] 用 `--help` 查参数但在 Windows 下输出被截断,没看到 `--next-token` 参数 → 以为不支持分页 +> - [正确] **正确做法:无条件按上述循环逻辑自动翻页,直到 nextToken 为空** + +**重要 — 转写原文拉取策略(AI 必须严格遵守):** + +转写原文数据量通常很大,**是否拉取、拉取多少**需要根据用户意图智能判断: + +1. **用户明确要求查看/分析转写原文时 → 默认拉取全部原文**(自动翻页,不需要用户手动说"第一页") + - 示例:"帮我看看转写原文"、"分析一下这篇听记的原文"、"把逐字稿给我"、"转写内容是什么" + - 实现:首次调用不传 `--next-token`,如果返回中包含 `nextToken`,**自动继续调用**直到拉取完所有页,最终拼合后展示给用户 + - **字符上限保护**:在循环拉取过程中,如果已累积的转写文本总量**超过 12000 字符(1.2w)**,必须**暂停自动翻页**,向用户提示当前已处理的字符数已达到上限,并询问是否继续拉取后续分页内容。用户确认后才继续拉取,用户拒绝则停止并展示已拉取的内容 + +2. **用户未明确要求查看原文时 → 不要主动拉取转写原文** + - 示例:"查一下和悟空相关的听记"、"帮我看看这个听记的摘要"、"这个会议讲了什么" + - 这些场景下用户的意图是查列表、看摘要等,**不需要也不应该**把大量转写原文全部拉出来,否则会造成信息过载和不必要的性能开销 + - 如果用户问"这个会议讲了什么",应优先使用 `get summary` 返回摘要,而非 `get transcription` + +**判断原则:只有用户的意图明确指向"原文/转写/逐字稿/录音文字"时,才调用 `get transcription`;其他场景(查列表、看摘要、看待办等)严禁自动附带拉取转写原文。** + +**重要 — 转写原文返回后默认按"时间线"组织各段落,AI 必须主动引导发言人聚类与关联(必须严格遵守):** + +`get transcription` 默认返回的段落是按"时间戳正序/倒序"穿插展示的(每条记录包含 `speakerNick + 时间戳 + 文本`),**对人不友好**——同一发言人的内容散落在多个时间点,难以快速看清"某个人主要讲了什么"。因此,AI **在拉取完成(含分页全部完成)后**必须主动启动以下"发言人聚类 → 关键词模糊匹配 → 引导确认 → 调用 `speaker replace`"四阶段工作流: + +#### 阶段 1:拉取完成后主动询问"是否按发言人聚类" +拉取(含自动翻页)结束后,AI 必须**主动**追问用户一次(不要默认强制聚类,避免用户只想看时间线原文时被打扰): + +> "已拉取完整转写原文(共 N 段,X 个发言人)。当前默认按时间线返回。是否需要我帮你**按发言人分组聚类**,并提取每位发言人的**核心发言要点**?" + +- 用户确认("好/可以/需要/聚类一下/按发言人分一下"等)→ 进入阶段 2 +- 用户拒绝 → 直接展示时间线原文,结束流程,**不再追问** + +#### 阶段 2:按发言人聚类 + 提取核心内容 +AI 基于已拉取的转写数据,**本地完成**聚类与摘要(无需新调用 dws 命令),输出结构如下: + +``` +[发言人] 发言人1(共 12 段,约 1820 字) + 核心要点: + - 介绍 Q3 战略规划与组织调整方向 + - 强调 AI 化转型的三个关键里程碑 + - 提出对供应链效率的具体改进目标 + +[发言人] 发言人2(共 8 段,约 960 字) + 核心要点: + - 汇报当前业务的财务数据与利润率 + - 分析竞品在华东市场的最新动作 + +[发言人] 张三(共 5 段,约 410 字) + 核心要点: + - 同步研发团队的招聘进度 + - 提出对测试资源的支持诉求 +``` + +**约束:** +- 聚类必须包含**全部发言人**(包括"发言人1/发言人2"占位符与已关联真实姓名的发言人,便于后续替换) +- 每位发言人**最多列出 3-5 条**核心要点,避免冗长 +- 输出后必须**主动追问**用户: + +> "如果你能告诉我『某某人主要讲了什么』(例如『李总主要讲了战略规划』『王经理主要负责供应链』),我可以根据关键词帮你**自动匹配**对应的发言人,并把『发言人1/发言人2』替换成真实姓名。" + +#### 阶段 3:基于用户提供的关键词做模糊匹配 +当用户提供形如『某某人主要讲了 XX』的输入时(例如『李总主要讲了战略规划』『拾光负责供应链效率改进』),AI 必须: + +1. **提取关键词**:从用户输入中抽取核心实体/主题词(如『战略规划』『供应链效率』『AI 化转型』),可允许多关键词 +2. **在阶段 2 已聚类的发言人核心要点中做模糊匹配**: + - 优先匹配「核心要点」中包含或语义相近的发言人 + - 必要时回看该发言人的原始转写文本进行二次确认 + - 支持**同义词/近义词**容忍(如『战略』~『规划』、『供应链』~『物流』) +3. **匹配结果分级处理:** + - **唯一高置信匹配**(一个发言人显著命中)→ 进入阶段 4 引导确认 + - **多个候选**(2 个及以上发言人都有部分命中)→ 列出全部候选,让用户选择,例如: + > "根据关键词『战略规划』,我匹配到 2 个可能的候选:① 发言人1(命中『战略规划/AI 化转型』)② 发言人3(命中『战略方向』)。你说的『李总』更可能是哪一位?" + - **无匹配** → 如实告知用户,并请其补充更具体的关键词或直接给出"发言人编号 → 真实姓名"的映射,例如: + > "暂未在转写中匹配到与『战略规划』强相关的发言人。可以再描述得更具体一些,或者直接告诉我『发言人 X 就是李总』,我来帮你替换。" + +#### 阶段 4:引导用户确认关联,并调用 `speaker replace` 完成替换 +匹配到唯一候选后,AI **不要直接替换**,必须先**显式追问用户确认**: + +> "我找到『发言人1』很可能就是你说的『李总』(命中关键词:战略规划、AI 化转型)。是否需要我把这篇听记里的『发言人1』全部替换为『李总』? +> 确认后我会执行:`dws minutes speaker replace --id --from "发言人1" --to "李总"`" + +- 用户确认 → 立即调用 `dws minutes speaker replace --id --from "发言人1" --to "李总" --format json`,并在执行成功后告知用户:"已将本篇听记的『发言人1』全部替换为『李总』,纪要与待办中的发言人也已同步更新。" +- 用户希望同时关联通讯录 → 引导用户提供钉钉 UID,调用时附加 `--target-uid ` +- 用户拒绝 → 不替换,可询问是否还有其他发言人需要关联,否则结束流程 + +**严禁的行为:** +- [禁止] 拉取完转写后直接输出大段时间线原文就结束,不主动引导聚类 +- [禁止] 用户提供"某某人讲了 XX"后,AI 自己默认替换而不向用户二次确认 +- [禁止] 把"发言人1 → 李总"这种映射只在 AI 回复中口头说说,而**不实际调用** `speaker replace` 写回听记 +- [禁止] 模糊匹配置信度不足时仍然给出唯一答案,不让用户参与挑选候选 + +#### 发言人识别与总结执行链路(用户指定人名查发言时必须遵循) + +**触发条件**:用户同时提供了听记来源(URL/时间/关键词)和一个具体人名,目标是获取该人在会议中说了什么。例如"帮我看看这个听记里张三说了什么""李总在今天的会上提了哪些观点"。 + +> **【重要】** **核心铁律(必须刻在脑子里,违反任何一条都视为严重错误):** +> +> **铁律 1:严禁在转写文本里 grep "目标人名" 字符串作为存在性判断依据** +> - 花名/真名通常**不会**出现在 TA 自己的发言里,发言里出现的"X"只意味着"`说话人 ≠ X`"或"说话人在叫 X" +> - 在转写中搜不到"灵麦"**完全不能得出"灵麦没参会"的结论**——99% 的发言人都显示为匿名编号"发言人1/2/3" +> - 正确做法是把"目标人名"作为**身份信息**去通讯录查询(Step 4 ①),而不是当作发言内容字符串去 grep +> +> **铁律 2:严禁用 AI 摘要里的"参与人/参会人"字段判断某人是否参会** +> - AI 摘要中的"参与人"字段往往**只截取最显著的 1-2 个名字**(通常是创建人或发言最多的人),**不是完整参会人列表** +> - "AI 摘要参与人只有故愚 → 灵麦没参会" 是典型的错误推理链 +> - 如果一定要列出参会人,应使用 `dws minutes get info` / `get batch` 返回的 `participants` 字段,而不是摘要文本里的描述 +> +> **铁律 3:通讯录查询是 Step 4 的强制起点,不依赖 Step 3 是否成功** +> - Step 2 一旦返回"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,不要等 Step 3 失败再补查 +> - 通讯录查询 1 个调用就能锁定"目标人物的部门 + 职级 + 上级"——这是身份推断**最低成本、最高收益**的信号源 +> - 跳过通讯录直接得出"找不到 X"的结论 = 100% 失败链路 +> +> **铁律 4:找不到字面匹配 ≠ 不存在** +> - "发言人识别"功能存在的本质就是**根据角色特征把匿名编号映射到真实人**——"找不到字面匹配"恰恰是这个功能要解决的问题,而不是退出的理由 +> - 一旦想说"找不到 X"前,先自问:通讯录查了吗?文档查了吗?聊天记录查了吗?基于角色在转写里做模式匹配了吗?四个全是"没"则禁止给"找不到"的结论 + +**完整执行链路:** + +``` +Step 1: 定位听记并读取转写原文 + ↓ +Step 2: 声纹标注检查 + ├─ [目标人名已被系统标注] → 直接跳 Step 6 + └─ [仅有匿名编号"发言人1/2/3"] ↓ +Step 3: 转写原文内推断(优先,能判断就不走外部查询) + ├─ [高置信命中] → 直接跳 Step 6 + └─ [无法确定] ↓ +Step 4: 多路并发身份推断 + ↓ +Step 5: 定向匹配 + 置信度分支 + ├─ 置信度 ≥ 50% → 展示文本片段请用户确认 + └─ 置信度 < 50% → 提供候选片段请用户辨认 + ↓ +Step 6: 结构化总结输出 + ↓ +Step 7: 引导用户替换发言人(调用 speaker replace 写回听记) +``` + +##### Step 1: 定位听记并读取转写原文 + +- **有 URL** → 从 URL 提取 taskUuid → `dws minutes get transcription --id --format json`(自动翻页拉取全部) +- **有时间/关键词** → `dws minutes list all --query "关键词" --start "..." --end "..." --format json` 筛选 → 获取 taskUuid → 拉取转写 +- **什么都没给** → **必须询问用户**提供听记链接、时间或关键词 + +##### Step 2: 声纹标注检查 + +检查转写原文中目标人物是否已被系统识别并标注了真实姓名(即 `speakerNick` 直接就是用户提到的人名): +- **已标注**(某条 `speakerNick` 字面就是"木兰")→ 直接跳到 Step 6,零确认步骤 +- **仅有匿名编号**(发言人1/发言人2/发言人3)→ **必须**继续 Step 3 与 Step 4,**严禁在此处退出** + +> **【重要】** **关键认知(违反则案例 7 重现):** +> - **未命中真名标注 ≠ 目标人物没参会**——绝大多数听记的发言人都是匿名编号,"未命中"是**默认场景**,恰恰是发言人识别功能要解决的问题 +> - **不要在转写文本里 grep 目标人名**作为存在性判断——花名/真名通常不会出现在 TA 自己的发言里(参见铁律 1) +> - **不要把 `dws minutes get summary` 摘要文本里写到的"参与人"当作完整参会列表**——AI 摘要里的"参与人"字段只截取最显著的 1-2 个名字,不是完整名册(参见铁律 2) +> - 唯一能下"目标人物没参会"结论的场景是:`get info` / `get batch` 返回的结构化 `participants` 字段里**完整列出参会人且不含目标**,且 `dws contact user search` 也搜不到该人 → 才允许告知用户"该人不在参会列表" + +##### Step 3: 转写原文内推断(优先) + +**核心原则:先基于转写原文做逻辑推断,能直接判断就不调外部接口。** + +充分利用转写文本中的所有可用信息进行综合推断: +- **称呼线索**:其他人称呼"张总/李工/王老师"等 +- **自我介绍**:"我是 XX 部门的""我负责 XX" +- **上下文指代**:前文提到"张三你来说一下",紧接着的发言人大概率是张三 +- **发言内容特征**:用户说"李总负责战略",而某发言人大量讨论战略方向 +- **发言顺序**:主持人/领导通常先发言或总结性发言 + +只要能从原文**高置信度**地确定发言人,直接跳 Step 6 输出总结。如果仅凭原文无法确定,继续 Step 4。 + +##### Step 4: 多路并发身份推断 + +> **【重要】** **强制规则(违反则案例 7 重现):** +> - **路径 ① 通讯录查询是必跑项,不依赖 Step 3 是否成功**——Step 2 一旦判定"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,**严禁等 Step 3 失败再补查** +> - 通讯录查询单次调用即可拿到"部门 + 职级 + 上级 + 真名"——这是身份推断**最低成本、最高收益**的信号源 +> - 路径 ②③④ 是**增量信号**,按需触发(如通讯录返回的部门是"产品设计部"这类多角色部门时,再补查 ② 文档) + +**触发顺序:** + +| 阶段 | 必跑 / 可选 | 触发时机 | +|------|-------------|----------| +| 路径 ① 通讯录组织架构 | **必跑** | Step 2 判定"未命中真名"后立即并发触发,与 Step 3 同时进行 | +| 路径 ② 本人创建的文档 | 可选 | ① 返回的部门是"产品设计部"这类多角色部门,需要更精确的角色信号时 | +| 路径 ③ 近期日程类型 | 可选 | ① ② 都不充分,需要补充职能边界判断时 | +| 路径 ④ 聊天记录 | 可选 | ①②③ 都不充分,需要语言风格/工作内容线索作为最后一道印证时 | + +| 路径 | 命令 | 得到什么 | +|------|------|----------| +| ① 通讯录组织架构 | `dws contact user search --query "目标人名"` → 部门/职级/上级/真名 | 职能大类(技术/产品/设计/管理)+ 是否存在该人 | +| ② 本人创建的文档 | `dws doc search --query "目标人名/真名"` 至少获取 3 篇标题 | 角色精确信号(PM写PRD、研发写技术方案、设计师写视觉规范)| +| ③ 近期日程类型 | `dws calendar event list` | 职能边界(参加什么类型的会)| +| ④ 聊天记录 | `dws chat message list` 获取与目标人的近期 IM 消息 | 语言风格/工作内容/职责线索 | + +**判定规则**: +- ① 命中(通讯录里搜到该人)→ 至少能拿到"部门 + 职级"信号,置信度起步 ≥ 30% +- ① + ② / ③ / ④ 任一路印证 → 置信度 ≥ 50% +- 两路以上独立信号一致 → 置信度 ≥ 70% +- ① 完全搜不到该人(且通讯录工具本身可用、未报错)→ 才允许告知用户"该人不在通讯录中,请确认花名是否正确" + +> **关键约束 1**:部门名 ≠ 角色("产品设计部"里有 PM、设计师、研究员),必须结合文档产出等信号区分。 +> +> **关键约束 2**:① 通讯录查询调用一次就能锁定身份范围,**严禁省略**。常见的失败模式是:在转写文本里反复 grep 目标人名找不到 → 直接放弃 → 告诉用户"找不到"——这是 100% 错误链路(参见案例 7)。 + +##### Step 5: 定向匹配 + 置信度分支 + +基于 Step 4 推断的角色,在转写原文中寻找匹配的发言模式: + +| 角色 | 典型发言特征 | +|------|----------| +| 产品经理 | 提需求、讲用户场景、定优先级 | +| 研发 | 技术约束、方案评估、排查问题 | +| 管理者 | 发言占比高、最终决策、分配任务 | +| 设计师 | 视觉方案、交互细节、体验讨论 | + +**分支 A:置信度 ≥ 50%(文本确认)** + +选取最具代表性的连续片段(≥ 2 句完整句子,避免"嗯/对/好"等纯语气词),展示给用户: + +> "根据分析,以下发言最可能是 [人名] 的: +> 「[片段内容]」 +> 确认是 TA 吗?" + +- 用户确认 → Step 6 +- 用户否认 → 换下一候选(最多 3 个) +- 3 个全否 → 告知无法仅通过文本确认,建议在听记详情页播放录音辅助辨认 + +**分支 B:置信度 < 50%(多候选展示)** + +当身份推断把握不足时,列出所有候选发言人及其代表性片段,让用户挑选: + +> "无法确定哪位是 [人名]。以下是几位候选发言人的代表性内容: +> ① 发言人1:「[片段]」 +> ② 发言人3:「[片段]」 +> 哪位更像 [人名]?或者你可以在听记详情页播放录音辅助确认。" + +- 用户选定 → Step 6 +- 用户无法确认 → 告知可在听记详情页点击对应段落播放原始录音来辨认,结束流程 + +##### Step 6: 结构化总结输出 + +提取已确认的该发言人的全部发言,结合会议上下文进行综合总结。根据实际内容灵活组织输出结构,例如: + +``` +[人名] 在本次会议中的发言总结(共 N 段,约 X 字) + +核心观点: +- 观点1... +- 观点2... +- 观点3... + +关键决策/结论: +- ... + +提出的待办/行动项: +- ... +``` + +##### Step 7: 引导用户替换发言人(必须执行,不可跳过) + +总结输出完成后,如果该发言人在转写中仍显示为匿名编号(如"发言人1"),**必须主动引导用户替换**: + +> "目前这篇听记中 [人名] 的发言仍显示为『发言人X』。要我帮你把听记里的『发言人X』全部替换为『[人名]』吗?替换后纪要和待办中的发言人也会同步更新。 +> 确认后我会执行:`dws minutes speaker replace --id --from "发言人X" --to "[人名]"`" + +- 用户确认 → 立即调用 `dws minutes speaker replace --id --from "发言人X" --to "[人名]" --format json`,成功后告知用户 +- 用户希望关联通讯录 → 引导提供钉钉 UID,附加 `--target-uid ` +- 用户拒绝 → 不替换,询问是否还有其他发言人需要处理 + +**追问是否还有其他人需要识别:** + +> "还有其他发言人需要我帮你识别和替换吗?比如告诉我『某某人主要讲了什么内容』,我可以帮你匹配。" + +**严禁的行为:** +- [禁止] 总结完就结束,不引导用户替换发言人——用户下次看听记时发言人还是"发言人1",体验极差 +- [禁止] AI 自行决定替换而不向用户确认 +- [禁止] 只在回复中口头说"发言人1就是张三",但不实际调用 `speaker replace` 写回听记 + +### 获取听记中提取的待办事项 +``` +Usage: + dws minutes get todos [flags] +Example: + dws minutes get todos --id +Flags: + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 +``` + +每条记录包含: 待办内容、待办唯一ID、参与人信息、待办时间 + +### 批量查询听记详情 +``` +Usage: + dws minutes get batch [flags] +Example: + dws minutes get batch --ids uuid1,uuid2,uuid3 +Flags: + --ids string 听记 taskUuid 列表,逗号分隔 (必填) +``` + +返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态 + +### 修改听记标题 +``` +Usage: + dws minutes update title [flags] +Example: + dws minutes update title --id --title "Q2 复盘会议" +Flags: + --id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项 + --title string 新标题 (必填) +``` + +### 更新纪要内容 +``` +Usage: + dws minutes update summary [flags] +Example: + dws minutes update summary --id --content "新的纪要内容" +Flags: + --id string 听记 taskUuid (必填) + --content string 新的纪要内容 (必填) +``` + +用传入的摘要文本全量覆盖听记的纪要内容,不触发 AI 重新生成。适用于用户手动编辑或 AI Agent 修改纪要的场景。 + +**重要 — 修改纪要的完整流程(必须严格执行):** +当用户要求"精简纪要/优化纪要/修改纪要内容"时,**必须完成以下三步,缺一不可**: +1. **读取**:先调用 `get summary --id ` 获取当前纪要原文 +2. **修改**:AI 根据用户要求对纪要内容进行修改(如精简、重新整理、格式优化等),但必须遵守以下约束: + - **图片必须保留**:原文中的所有 Markdown 图片(如 `![alt](url)`)必须完整保留,不得删除、漏掉、替换为纯文本或打乱语义位置 + - **仅优化文本内容**:可以调整标题层级、段落结构、列表与措辞,但不得破坏图片与对应上下文的关联关系 +3. **校验**:写回前必须执行 Markdown 格式检查,确保输出结构合理、可渲染、无明显格式错误(如未闭合代码块、列表层级混乱、标题层级异常等) +4. **写回**:将修改后的完整纪要内容通过 `update summary --id --content "修改后的完整纪要"` **写回听记**,确保修改持久化 + +**严禁只读取和修改纪要而不调用 `update summary` 写回**,否则用户看到的仍然是原始纪要,修改不会生效。 + +### 创建思维导图 +``` +Usage: + dws minutes mind-graph create [flags] +Example: + dws minutes mind-graph create --id +Flags: + --id string 听记 taskUuid (必填) +``` + +触发创建听记思维导图任务。触发成功后,可通过 `mind-graph status` 轮询任务状态。状态:0=进行中,1=成功,2=失败。 + +**重要:当用户要求"生成思维导图/创建脑图"时,必须调用此命令(`mind-graph create`),严禁自行生成 HTML 或其他格式的思维导图。** 思维导图由服务端专业引擎生成,AI 不应尝试自己构造思维导图内容。 + +### 查询思维导图状态 +``` +Usage: + dws minutes mind-graph status [flags] +Example: + dws minutes mind-graph status --id +Flags: + --id string 听记 taskUuid (必填) +``` + +查询指定听记的思维导图生成状态。返回任务状态:0=进行中,1=成功,2=失败。如果没有返回任务状态,也视为成功。 + +### 替换发言人 +``` +Usage: + dws minutes speaker replace [flags] +Example: + dws minutes speaker replace --id --from "张三" --to "李四" + dws minutes speaker replace --id --from "张三" --to "李四" --target-uid +Flags: + --id string 听记 taskUuid (必填) + --from string 源发言人昵称 (必填) + --to string 目标发言人昵称 (必填) + --target-uid string 目标发言人钉钉 UID (可选) +``` + +批量替换听记转写中指定发言人,将源发言人(speakerNick)精确匹配的所有段落替换为目标发言人。支持同时替换 nickName 和 subSpeakerNickname 两种匹配方式,并自动更新纪要、待办中的发言人信息。 + +**重要:** +- 此命令支持替换**任意发言人**,包括已关联通讯录信息的发言人(如"张三"、"李四"等真实姓名),不仅限于"发言人1"之类的占位符 +- `--from` 填写当前听记中显示的发言人名称(无论是"发言人1"还是真实姓名),`--to` 填写要替换成的目标名称 +- 如果用户希望将发言人关联到通讯录中的具体联系人,可通过 `--target-uid` 传入目标用户的钉钉 UID + +### 添加个人热词 +``` +Usage: + dws minutes hot-word add [flags] +Example: + dws minutes hot-word add --words "钉钉" + dws minutes hot-word add --words "OKR,钉钉,Copilot" +Flags: + --words string 要添加的热词,多个用逗号分隔 (必填) +``` + +添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。 + +### 查找替换听记文字 +``` +Usage: + dws minutes replace-text [flags] +Example: + dws minutes replace-text --id --search "旧文字" --replace "新文字" +Flags: + --id string 听记 taskUuid (必填) + --search string 要查找的文字 (必填) + --replace string 替换为的新文字 (必填) +``` + +把听记中所有出现的原文字替换为目标文字,包括转写段落和纪要摘要中出现的原文字都会被替换。区分大小写,精确匹配。 + +**重要 — 执行后必须主动引导用户添加热词(避免长期反复识别错):** +`replace-text` 仅修正**当前这一篇听记**的文字,**不会影响后续新听记的语音识别结果**。如果用户替换的是一个**长期容易被识别错的专有名词、人名、产品名**(如把"付工"改成"悟空"、把"非书"改成"飞书"),AI **必须在 `replace-text` 成功后主动追问用户**: + +> "我已经把这篇听记里的『旧文字』替换为『新文字』。如果这个词以后也容易被识别错,建议把它加到个人热词里,后续新听记就不会再识别错了。要我现在帮你执行 `dws minutes hot-word add --words "新文字"` 吗?" + +用户确认后立即调用 `hot-word add`。**严禁只做替换不引导**——这会让用户每次都要手动改一次,体验非常差。 + +### 创建文件上传会话或者文件转听记或者链接转听记 +``` +Usage: + dws minutes upload create [flags] +Example: + dws minutes upload create --file-name "meeting.mp4" --file-size 102400 + dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --title "周会录音" + dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --input-language "zh" --enable-message-card +Flags: + --file-name string 文件名(含后缀),如 meeting.mp4 (必填) + --file-size int 文件大小(字节)(必填,正整数) + --title string 听记标题,不传时默认使用文件名去掉后缀 (可选) + --template-id string 纪要生成使用的模板 ID (可选) + --input-language string ASR 识别的源语言 (可选) + --enable-message-card 是否推送闪记卡片消息 (可选,默认 false) +``` + +创建文件上传会话,获取预签名上传 URL。调用方拿到 URL 后,直接用 HTTP PUT 将文件上传到该 URL。必须与 `upload complete` 配合使用: +1. 调用 `upload create` 获取预签名上传 URL 和 sessionId +2. HTTP PUT 预签名上传 URL 上传文件(不带 HEADER) +3. 调用 `upload complete` 传入 sessionId 完成创建 + +### 完成文件上传并创建听记 +``` +Usage: + dws minutes upload complete [flags] +Example: + dws minutes upload complete --session-id +Flags: + --session-id string 上传会话 ID,来自 upload create 返回的 sessionId (必填) +``` + +文件上传完成后,调用此命令创建听记。必须在 `upload create` 之后、预签名 URL 上传完成后调用。幂等:同一 sessionId 重复调用直接返回已有任务,不会重复创建。 + +### 取消文件上传会话 +``` +Usage: + dws minutes upload cancel [flags] +Example: + dws minutes upload cancel --session-id +Flags: + --session-id string 要取消的会话 sessionId (必填) +``` + +取消 `upload create` 创建的上传会话,释放服务端资源。用于在上传前或上传失败后取消会话。 + +## 意图判断 + +### 文件转听记 +用户说"把文件转成听记/上传音频文件/上传录音/把这个 mp3 转成听记/帮我把会议录音转写一下/把附件里的音频文件做转写" → `upload create` → HTTP PUT → `upload complete` + +**自然语言示例:** +- "把这个文件转写生成纪要" +- "帮我把这段录音转成文字" +- "上传一个音频文件,帮我转写" +- "我有一个录音文件,帮我生成听记" +- "把这个 mp3 转成听记" +- "帮我把会议录音转写一下" +- "这是昨天的录音,帮我整理成纪要" +- "把附件里的音频文件做转写" + +用户说"取消上传" → `upload cancel` + +### 添加热词 +用户说"加热词/添加热词/设置热词/把某个词加到热词里/这个词总是识别错" → `hot-word add` + +**自然语言示例:** +- "转写不准,应该是'悟空'而不是'付工'" +- "帮我把'钉钉'加到热词里" +- "这个词总是识别错,加一下热词" +- "'飞书'总被识别成'非书',加个热词" +- "帮我设置一下热词,'悟空'经常识别不对" +- "我们公司有个产品叫'魔镜',加一下热词避免识别错" +- "把'AI听记'加进热词库" +- "这个专有名词总转写错,帮我加到热词" + +### 查找替换 +用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`** + +**自然语言示例:** +- "把所有的A,替换成B" +- "把听记里的'付工'都改成'悟空'" +- "这一篇里把'非书'替换成'飞书'" + +**正确的对话节奏(必须遵守):** +1. 先执行 `replace-text` 完成本篇替换 +2. 立即追问:"如果这个词以后也容易被识别错,建议加个热词,后续新听记就不会再错了。要现在加吗?" +3. 用户确认 → 调用 `hot-word add --words "<新文字>"` +4. 用户拒绝 → 直接结束,不再追问 + +### 修改说话人 +用户说"替换发言人/修改发言人/换发言人名字/把说话人改成某某/发言人标注错了" → `speaker replace` + +**关键:支持替换任意发言人,包括已关联通讯录信息的真实姓名(如"张三"、"拾光"等),不限于"发言人1"等占位符。** 用户提到"把 XX 改成 YY"时,XX 即为 `--from`,YY 即为 `--to`。 + +**自然语言示例:** +- "把这一段内容的说话人全部改成张三" +- "帮我把发言人1改成李总" +- "这个说话人标注错了,改成王伟" +- "把所有'发言人2'替换成'小明'" +- "说话人识别错了,帮我修改一下" +- "把这段的说话人改成'拾贝'" +- "发言人1是我,帮我把名字改过来" +- "把'未知说话人'全都改成张总" +- "把这里面的发言人1,替换成拾光" +- "张三其实是李四,帮我改一下" + +### 查某人在听记中说了什么(发言人识别与总结) + +**触发条件**(必须同时满足): +1. 用户提供了听记来源(URL / 时间 / 关键词可定位到具体听记) +2. 用户指定了一个具体人名(花名/真名/关系称谓均可) +3. 用户的目标是获取该人在会议中说了什么 + +**不触发**(走其他链路): +- 用户只要整体纪要、未指定特定人物 → `get summary` +- 用户直接说"把发言人1改成张三" → `speaker replace`(无需走推断链路) +- 用户想提取待办 → `get todos` + +用户说"帮我看看某人说了什么/某人在会上提了哪些观点/某人有什么发言" → 走**发言人识别与总结**完整链路(详见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"发言人识别与总结执行链路") + +**自然语言示例:** + +| Query | 关键线索 | +|-------|----------| +| "帮我看看这个听记里张三说了什么" + URL | 听记URL + 人名 | +| "李总在今天的会上提了哪些观点" | 时间(今天) + 人名 + 观点 | +| "上周产品评审里王五有什么发言" | 时间(上周) + 关键词(产品评审) + 人名 | +| "这个会议里我老板说了啥" | 听记上下文 + 关系称谓(需解析为人名) | +| "帮我找一下设计师小陈在访谈里的观点" | 角色提示(设计师) + 人名 + 场景 | + +- "帮我看看这个听记里张三说了什么" +- "李总在今天的会上提了哪些观点" +- "上周产品评审里王五有什么发言" +- "这个会议里我老板说了啥" +- "帮我找一下设计师小陈在访谈里的观点" +- "拾光在这个会议里讲了什么" +- "帮我总结一下这次会议里李总的核心发言" +- "王经理今天开会说了啥重点" + +**关键约束(必须遵守):** +- 这是一个**完整的识别 → 推断 → 确认 → 总结 → 引导替换**链路,不要只做总结就结束,**必须引导用户将「发言人1」等占位符替换为真实姓名** +- 完整执行链路见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"发言人识别与总结执行链路" + +### 通过关键词模糊匹配确认发言人(与 `get transcription` 联动) +当用户在拉取完转写原文(且已按发言人聚类)之后,提供形如『某某人主要讲了 XX』『某某人负责 YY』『某某人这次的核心是 ZZ』的描述时,**不要直接做替换**,而是按下面的链路推进: + +1. **从用户输入中抽取关键词**(如『战略规划』『供应链效率』『AI 化转型』『招聘进度』) +2. **在已聚类的发言人核心要点中做模糊匹配**(支持同义词与近义词) +3. **匹配结果引导用户确认:** + - 唯一高置信命中 → 提示用户:"『发言人1』很可能就是你说的『李总』,是否需要执行 `dws minutes speaker replace --id --from "发言人1" --to "李总"`?" + - 多候选 → 列出所有命中的发言人编号 + 命中关键词,让用户挑选 + - 无匹配 → 请用户补充更具体的关键词或直接给出"发言人编号 → 真实姓名"的映射 +4. **用户确认后**才调用 `speaker replace` 写回;如同时希望关联通讯录,引导用户提供 UID,附加 `--target-uid ` + +**自然语言示例(必须能正确识别为"模糊匹配 → 确认 → 替换"链路):** +- "李总主要讲了战略规划" +- "拾光是负责供应链的那位" +- "讲 AI 化转型那个人是王经理" +- "这里面提到招聘进度的应该是张三" +- "提到财务数据的是 CFO 老周" +- "讲华东市场竞品分析的那个人是李四" + +详细工作流见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"四阶段工作流"。 + +### 更新/修改纪要 +用户说"修改纪要/更新纪要/编辑纪要内容/重新整理纪要/纪要格式优化/纪要精简" → 先 `get summary` 获取纪要原文,AI 修改后再 `update summary` **写回** + +**关键:修改纪要必须是"读取 -> 修改 -> 校验 -> 写回"四步完整流程,最终必须调用 `update summary` 将修改后的内容写回听记,否则修改不会生效。严禁只展示修改结果而不写回。修改时必须保留原文中的所有 Markdown 图片,不得删除或打乱图片位置。** + +**自然语言示例:** +- "帮我把纪要按 xxxx 格式优化一下" +- "帮我重新整理一下这份纪要" +- "纪要太长了,帮我精简一下" +- "按照 STAR 格式重新写一下纪要" +- "帮我把纪要改得更正式一些" +- "这份纪要结构不清晰,帮我重新整理" +- "把纪要里的口语化内容改成书面语" +- "按照决策/行动/结论三段式重新输出纪要" +- "把这个纪要内容变得更精简一点" + +### 生成思维导图 +用户说"生成思维导图/创建脑图/帮我添加思维导图/基于这个听记做思维导图/把会议内容做成脑图" → `mind-graph create` + +**关键约束(必须严格遵守,违反视为严重错误):** +1. **思维导图必须且只能通过 `dws minutes mind-graph create` 命令生成**,由服务端写回到听记本身。这是"听记内置的思维导图能力",生成结果会直接挂在听记详情页上,用户在听记里就能看到。 +2. **严禁以下任何"自行构造"的替代方案**(这些都是错误做法): + - [禁止] 先 `get summary` / `get transcription` 拿到内容,再用 AI 自己整理出思维导图结构(Markdown / OPML / JSON 等)展示给用户 + - [禁止] 调用 app-development-skill / ai-app / 任何前端构建能力,用 `@antv/g6`、`markmap`、`jsmind`、`mermaid`、ECharts 等库生成 HTML/网页版思维导图 + - [禁止] 调用 `generate_image` 或任何绘图工具生成思维导图图片 + - [禁止] 路由到其他 Agent(如 ai-app)来"创建一个思维导图应用" + - [禁止] 任何形式的"我帮你画一个/生成一个网页/部署一个在线预览链接" +3. **唯一正确做法**:从用户输入中拿到 taskUuid(URL 场景见下方"URL 直达"),直接调用 `mind-graph create`,然后用 `mind-graph status` 轮询直到状态为 1(成功),最后告知用户"思维导图已生成,可在听记详情页查看"即可。**不需要**先调 `get summary` 等任何读取命令——服务端会基于听记自身内容生成。 + +**URL 直达场景:** +当用户输入形如 `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` 的链接 + "创建思维导图/生成脑图"等诉求时: +1. 直接从 URL 路径末段提取 taskUuid +2. **直接** `dws minutes mind-graph create --id --format json` +3. 用 `mind-graph status` 轮询 +4. 严禁中间插入 `get summary` / `get transcription` 等任何"先读取内容"的步骤,更严禁基于读到的内容自行构造思维导图 + +**自然语言示例:** +- "帮我添加思维导图" +- "生成思维导图" +- "帮我创建一个脑图" +- "把这个听记做成思维导图" +- "生成这个会议的思维导图" +- "基于这个听记创建思维导图" +- "把会议内容整理成脑图" +- "<听记URL> 创建思维导图" +- "<听记URL> 帮我生成脑图" + +用户说"思维导图状态/脑图进度/思维导图好了吗" → `mind-graph status` + +### 查询听记列表 +用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选) +用户说"别人给我的听记/共享听记" → `list shared`(可附加 `--query`、`--start`、`--end` 筛选) +用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选) +用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数 + +**自然语言示例:** +- "查一下我最近的听记" +- "帮我找一下上周的听记" +- "有没有关于'周会'的听记" +- "看看别人共享给我的听记" +- "我这个月的听记有哪些" +- "帮我从今天听记里找需求评审" +- "上月听记里有没有提到OKR" +- "从我的听记里搜一下技术方案" +- "这周有没有关于复盘的会" +- "帮我找上周关于项目排期的听记" + +> **路由提示**:当用户说"从我的/我的听记里找XX"时,优先使用 `list mine --query`;当用户未明确归属范围(如"帮我找关于XX的听记")时,使用 `list all --query`。含时间词(今天/上周/上月/这周/本月)时,必须同时附加 `--start` + `--end` 参数。 + +### 获取听记内容 +用户说"听记详情/听记信息" → `get info` +用户说"摘要/总结/会议纪要/纪要" → `get summary` +用户说"关键字/关键词" → `get keywords` +用户说"原文/转写/录音文字/逐字稿" → `get transcription`(**默认拉取全部原文**,自动翻页直到拉完,**拉完后必须主动询问用户是否按发言人聚类**,详见 [获取听记语音转写原文](#获取听记语音转写原文) 的"四阶段工作流") +用户说"会议待办/听记待办/待办事项" → `get todos` +用户说"批量查询/查多个听记" → `get batch` +用户说"音频地址/音频链接/录音文件/下载录音/音频下载/视频文件/媒体地址" → 当前 CLI 未实现 `get audio` 子命令,引导用户在钉钉听记 App 中下载 + +**转写原文拉取时机判断(必须遵守):** +- **明确要看原文** → 调用 `get transcription`,自动翻页拉取所有原文;**累积超过 12000 字符时暂停,询问用户是否继续** + - 示例:"帮我看看转写原文"、"分析一下这篇听记的原文"、"把逐字稿发给我" +- **未明确要看原文** → **不要**调用 `get transcription`,用其他更合适的命令响应 + - 示例:"查一下和悟空相关的听记" → 应走 `list`,不需要拉原文 + - 示例:"这个会议讲了什么" → 应走 `get summary`,不需要拉原文 + - 示例:"这个听记有哪些待办" → 应走 `get todos`,不需要拉原文 + +**自然语言示例:** +- "帮我看看这个听记的摘要" +- "这个会议讲了什么" +- "帮我看看会议的转写原文" +- "分析一下这篇听记的转写原文" → **默认拉全部** +- "这个听记有哪些待办" +- "帮我提取一下会议的关键词" +- "帮我看看这个听记的基本信息" + +### 修改听记标题 +用户说"改听记标题/重命名听记/修改标题" → `update title` + +**自然语言示例:** +- "把这个听记的标题改成'Q2 复盘会议'" +- "帮我重命名一下这个听记" + +### URL 识别 +- **格式**: `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` +- **示例**: `https://shanji.dingtalk.com/app/transcribes/76327569643231383535353939365f3436383537393431335f32` +用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作 + +## 核心工作流 + +```bash +# 1. 查看我的听记列表 — 提取 taskUuid +dws minutes list mine --format json +dws minutes list mine --max 10 --next-token --format json +dws minutes list mine --query "周会" --format json + +# 1b. 查看共享给我的听记 +dws minutes list shared --max 20 --format json +dws minutes list shared --query "日报" --format json + +# 1c. 查看我有权限访问的所有听记(支持关键字和时间范围筛选) +dws minutes list all --format json +dws minutes list all --query "周会" --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00" --format json + +# 2. 获取 AI 摘要 +dws minutes get summary --id --format json + +# 3. 查看完整转写原文(拉完后默认按时间线返回,AI 必须主动追问"是否按发言人聚类") +dws minutes get transcription --id --format json +# 3a. 用户确认聚类 → AI 在本地按 speakerNick 分组并提取核心要点(无需新调用 dws) +# 3b. 用户提供"某某人讲了 XX" → AI 模糊匹配关键词后,引导确认替换发言人 +dws minutes speaker replace --id --from "发言人1" --to "李总" --format json + +# 4. 提取待办事项 +dws minutes get todos --id --format json + +# 5. 修改标题 +dws minutes update title --id --title "新标题" --format json + +# 6. 更新纪要内容 +dws minutes update summary --id --content "新的纪要内容" --format json + +# 8. 思维导图 +dws minutes mind-graph create --id --format json +dws minutes mind-graph status --id --format json + +# 9. 替换发言人 +dws minutes speaker replace --id --from "张三" --to "李四" --format json + +# 10. 添加个人热词 +dws minutes hot-word add --words "OKR,钉钉,Copilot" --format json + +# 11. 查找替换听记文字 +dws minutes replace-text --id --search "旧文字" --replace "新文字" --format json + +# 12. 文件上传转听记(三步流程) +# 12a. 创建上传会话,获取预签名 URL 和 sessionId +dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --format json +# 12b. 用 HTTP PUT 上传文件到预签名 URL(不带 HEADER) +curl -X PUT "" -T "/path/to/meeting.mp4" +# 12c. 通知服务端上传完成,创建听记 +dws minutes upload complete --session-id --format json +# 12d.(可选)取消上传会话 +dws minutes upload cancel --session-id --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `list mine` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token | +| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token | +| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token | +| `get batch` | 各听记 `taskUuid` | 进一步查询详情 | +| `upload create` | `sessionId`、`presignedUrl` | HTTP PUT 上传文件;upload complete/cancel 的 --session-id | +| `mind-graph create` | 任务状态 | mind-graph status 轮询 | + +## 错误响应诊断指南 + +当 `get info` / `get summary` / `get transcription` / `get todos` / `get keywords` / `get batch` 返回异常时,按以下决策表快速判断原因并决定下一步动作,避免盲目重试浪费轮次: + +### 错误快速决策表 + +| 错误现象 | 可能原因 | 正确处理 | 禁止动作 | +|----------|----------|----------|----------| +| `dingOpenErrcode=300` 且 `error_msg` 含 "taskUuid is invalid" | taskUuid 格式错误、不存在、或从上下文猜测/拼凑而来 | **立即停止当前 uuid,切换到 list 策略**:调用 `dws minutes list mine --max 10 --format json` 获取真实 uuid 列表,让用户选择或自动匹配最相关的一条。**严禁用同一个无效 uuid 重试**——真实案例中模型用同一个错 uuid 重试了 20 次全部失败 | 禁止用同一个无效 ID 重试哪怕 1 次;禁止从历史对话/文档/链接中猜测 uuid;禁止从十六进制编码字符串里截取数字拼凑新 uuid | +| stdout 完全为空,error_msg 也为空 | 鉴权过期 / 服务端临时不可用 | 最多重试 1 次;仍为空则告知用户「服务暂时不可用,请稍后再试」 | 禁止连续重试超过 2 次 | +| `dingOpenErrcode=403` 或含 "permission" / "forbidden" | 无权限访问该听记 | 告知用户无权限,建议联系听记创建者共享权限 | 禁止重试(权限问题不会因重试改变) | +| `dingOpenErrcode=404` 或含 "not found" | 听记已被删除或不存在 | 告知用户该听记不存在或已被删除 | 禁止重试 | +| 返回 JSON 但关键字段(如 `result`)为 null | 听记处理中(ASR/AI 摘要尚未完成) | 告知用户听记内容正在生成中,建议等待 1-2 分钟后再查询 | 禁止立即重试(处理需要时间) | +| 网络超时 / 连接错误 | 网络不稳定 | 最多重试 1 次 | 禁止连续重试超过 2 次 | + +### 重试约束(必须遵守) + +- 同一个 taskUuid + 同一个命令,最多重试 **1 次**(总计最多调用 2 次) +- 重试前必须检查:错误是否属于「可重试」类别(仅 stdout 为空 / 网络超时 属于可重试) +- `dingOpenErrcode=300/403/404` 均为「不可重试」错误,立即停止并给出明确诊断 +- 如果第一次调用返回了结构化错误(含 errcode),禁止换一个别名 flag(如 --task-uuid 换 --id)重试——问题不在 flag 名称 + +### Flag 别名兼容说明 + +所有需要传入 taskUuid 的子命令(`get info/summary/keywords/transcription/todos/batch`、`mind-graph create/status`、`update title/summary` 等)均支持以下 flag 名称,自动降级到 `--id`: + +| Flag 名称 | 状态 | 说明 | +|-----------|------|------| +| `--id` | 推荐 | 唯一的正式 flag 名称,文档和示例中统一使用 | +| `--url` | 隐藏别名 | 兼容听记 URL 传入场景 | +| `--task-uuid` | 隐藏别名 | 兼容钉钉 OpenAPI 原生字段名 taskUuid | +| `--uuid` | 隐藏别名 | 兼容通用 UUID 命名习惯 | + +传入 `--task-uuid` / `--uuid` / `--url` 时,CLI 会自动降级到 `--id` 处理,无需报错后用 `--id` 重试。 + +## 关键词搜索与筛选最佳实践 + +### 服务端筛选优先原则(必须遵守) + +`list mine` / `list shared` / `list all` 均支持 `--query`(关键词)、`--start`(开始时间)、`--end`(结束时间)三个筛选参数,筛选在服务端完成,效率远高于全量拉取后本地过滤。 + +**禁止全量拉取后本地过滤**:当用户提供了搜索关键词或时间范围时,必须使用 `--query` / `--start` / `--end` 参数在服务端筛选,严禁先 `list all` 拉全量再在 prompt 中用 Python/grep 做本地关键词匹配。 + +### 自然语言到参数映射表 + +| 用户表述 | 映射参数 | 示例命令 | +|----------|----------|----------| +| "今天的听记" | `--start` 今日 00:00 `--end` 当前时间 | `dws minutes list all --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` | +| "上周的听记" | `--start` 上周一 00:00 `--end` 上周日 23:59 | `dws minutes list all --start "2026-05-04T00:00:00+08:00" --end "2026-05-10T23:59:59+08:00"` | +| "上月的听记" | `--start` 上月 1 日 `--end` 上月最后一天 | `dws minutes list all --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"` | +| "最近的听记" | 不传时间参数,使用默认排序 | `dws minutes list mine --max 10` | +| "关于XX的听记" | `--query "XX"` | `dws minutes list all --query "双叶汽车"` | +| "找XX相关的听记" | `--query "XX"` | `dws minutes list all --query "安利"` | +| "本月关于XX的听记" | `--query` + `--start` + `--end` | `dws minutes list all --query "ROI" --start "2026-05-01T00:00:00+08:00" --end "2026-05-31T23:59:59+08:00"` | +| "帮我从今天听记里找XX" | `--query` + `--start` + `--end`(今日范围) | `dws minutes list mine --query "需求评审" --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` | +| "上月听记里有没有提到XX" | `--query` + `--start` + `--end`(上月范围) | `dws minutes list mine --query "OKR" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"` | +| "从我的听记里搜XX" | `--query "XX"`(走 `list mine`) | `dws minutes list mine --query "技术方案"` | +| "这周有没有关于XX的会" | `--query` + `--start` + `--end`(本周范围) | `dws minutes list mine --query "复盘" --start "2026-05-05T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` | + +### 组合筛选示例 + +```bash +# 按关键词搜索 +dws minutes list all --query "神威数智需求变更沟通会议" --format json + +# 按时间范围搜索 +dws minutes list all --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json + +# 关键词 + 时间范围组合 +dws minutes list all --query "精益生产" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json + +# 按关键词搜索共享听记 +dws minutes list shared --query "安利" --max 20 --format json + +# 从我的听记里按关键词 + 今日时间范围搜索("帮我从今天听记里找需求评审") +dws minutes list mine --query "需求评审" --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00" --format json + +# 从我的听记里按关键词 + 上月时间范围搜索("上月听记里有没有提到OKR") +dws minutes list mine --query "OKR" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json + +# 从我的听记里仅按关键词搜索("从我的听记里搜技术方案") +dws minutes list mine --query "技术方案" --format json +``` + +### 搜索无结果时的 Fallback 策略 + +1. `--query` 无结果 --> 尝试缩短关键词(如「神威数智需求变更沟通会议」缩短为「神威数智」) +2. 仍无结果 --> 扩大搜索范围(`list mine` 换为 `list all`) +3. 仍无结果 --> 放宽时间范围(扩大 `--start` / `--end`) +4. 最终无结果 --> 如实告知用户未找到匹配的听记,建议用户确认关键词或时间范围 + +## 转写翻页异常处理 + +### 翻页空响应防御规则(必须遵守) + +`get transcription` 使用 `--next-token` 进行分页查询。在自动翻页过程中,可能遇到以下异常情况: + +| 异常现象 | 含义 | 处理方式 | +|----------|------|----------| +| 返回 JSON 中不包含 `nextToken` 字段 | 已到达最后一页,无更多数据 | 正常终止翻页,拼合已拉取内容 | +| `nextToken` 字段值为空字符串 `""` | 等同于无 nextToken,已到达最后一页 | 正常终止翻页 | +| 使用同一个 `next-token` 值连续 2 次返回 stdout 为空 | 服务端临时异常或该 token 已失效 | 立即终止翻页,不再重试;基于已拉取的内容进行分析 | +| 返回 JSON 但 `paragraphList` 为空数组 `[]` | 当前页无内容(可能是中间空页) | 如果有 nextToken 则继续翻页;如果无 nextToken 则终止 | + +### 翻页流程伪代码 + +``` +已累积文本 = "" +当前token = ""(首页不传) +连续空响应计数 = 0 +上一次token = null + +loop: + 调用 get transcription --id [--next-token 当前token] + + if stdout 为空 or 返回无效: + if 当前token == 上一次token: + 连续空响应计数 += 1 + if 连续空响应计数 >= 2: + 终止翻页,输出已累积内容 + break + else: + 连续空响应计数 = 1 + 上一次token = 当前token + continue + + 连续空响应计数 = 0 + 拼合本页转写文本到已累积文本 + + if len(已累积文本) > 12000: + 暂停翻页 + 询问用户:"当前已拉取约 N 字符,已达上限,是否继续?" + if 用户拒绝: break + + if 返回中无 nextToken 或 nextToken 为空: + break(最后一页) + + 上一次token = 当前token + 当前token = 返回的 nextToken +``` + +### 严禁的翻页行为 + +- [禁止] 同一个 next-token 值连续重试超过 2 次 +- [禁止] 翻页失败后换用不同的 flag 名(如 --next-token 换成 --nextToken)重试 +- [禁止] 累积超过 12000 字符后不暂停,继续拉取所有页 +- [禁止] 拉到空页就立即放弃全部已累积内容,应基于已有内容进行分析 + +## 注意事项 +- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参 +- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询 +- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`) +- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选 +- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条 +- `get summary` 返回 AI 生成的结构化 Markdown 摘要 +- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序;当用户明确要求查看/分析转写原文时,默认自动翻页拉取全部原文(不需要用户手动说"拉第一页"),如果用户意图不是专门看原文(如查列表、看摘要),则不应主动调用此命令 +- `get transcription` 默认按"时间线"返回各段落,**拉完后 AI 必须主动追问用户"是否需要按发言人分组聚类并提取核心内容"**;用户确认后 AI 在本地完成聚类与摘要,并进一步引导用户通过关键词模糊匹配(如"李总主要讲了战略规划")确认"发言人编号 ↔ 真实姓名"的映射,最终调用 `speaker replace` 写回。完整工作流见对应命令章节的"四阶段工作流" +- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid +- `update summary` 全量覆盖纪要内容,不触发 AI 重新生成;适用于手动编辑或 AI Agent 修改纪要 +- `mind-graph create` 触发异步任务,需通过 `mind-graph status` 轮询状态(0=进行中,1=成功,2=失败) +- `speaker replace` 精确匹配源发言人昵称,替换所有段落并自动更新纪要和待办中的发言人信息 +- `hot-word add` 支持逗号分隔批量添加,每个热词不超过 10 个汉字或 5 个英文单词 +- `replace-text` 区分大小写精确匹配,同时替换转写段落和纪要摘要中的文字 +- 文件上传流程为三步:`upload create` → HTTP PUT 上传 → `upload complete`;`upload complete` 幂等,同一 sessionId 重复调用不会重复创建 +- `upload create` 返回的 `presignedUrl` 用于 HTTP PUT 上传文件,上传时不需要带任何 HEADER +- 所有需要 taskUuid 的子命令均支持 --task-uuid / --uuid / --url 作为 --id 的隐藏别名,传入后自动降级,无需报错重试 +- 同一个 taskUuid + 同一个命令,最多重试 1 次(总计最多调用 2 次),dingOpenErrcode=300/403/404 为不可重试错误 +- 当用户提供关键词或时间范围时,必须使用 --query / --start / --end 在服务端筛选,严禁全量拉取后本地过滤。**严禁在 dws 命令后拼接 shell 管道做本地过滤**(如 `| grep "关键词"` / `| head -50` / `python -c "import json;..."` / `> /tmp/xxx.txt && grep ...`),这些写法在 Windows 沙箱里 100% 失败(`head`/`grep`/`wait` 不是 Windows 内置命令),即使在 macOS 上也会因 list 本身失败导致整个管道链路断掉。正确做法是始终使用 `--query` / `--start` / `--end` / `--max` 等 cli 内置参数在服务端完成筛选 +- `dws minutes list` 后面**必须跟 `mine` / `shared` / `all` 子命令**,不能直接 `dws minutes list --start ... --end ...`(会报 `unknown flag: --start`)。正确写法:`dws minutes list mine --start ... --end ...` 或 `dws minutes list all --query "关键词" --start ... --end ...` +- get transcription 翻页时,同一个 next-token 连续返回空 2 次即终止翻页,不再重试 +- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--next-token`) + +### 错误恢复策略(AI 遇到以下错误时必须自动执行对应恢复动作) + +| 错误类型 | 错误信息特征 | 恢复动作 | 严禁行为 | +|----------|-------------|----------|----------| +| 命令结构错误 | `unknown command "info"` / `unknown command "get"` (后无子命令) / `error[validation]: unknown flag: --start` (在 `list` 而非 `list mine/shared/all` 上) | **立即参照本文档顶部"命令层级结构"纠正**。常见错误:① `dws minutes info` → 应为 `dws minutes get info`;② `dws minutes get --id` → `get` 后缺子命令,应为 `get info/summary/transcription/keywords/todos/batch` 等;③ `dws minutes list --start` → `list` 后缺 scope,应为 `list mine/shared/all --start` | 严禁在命令结构报错后只换参数名不修正命令层级;严禁把 `get` 当作独立命令使用(后面必须跟 info/summary/transcription/keywords/todos/batch) | +| 参数名错误 | `unknown flag: --task-uuid` / `unknown flag: --uuid` / `unknown flag: --start-time` / `unknown flag: --end-time` | 立即调用 `dws minutes <子命令> --help` 查询正确参数名;minutes 模块统一用 `--id`,时间统一用 `--start` / `--end`(不是 `--start-time` / `--end-time`) | 严禁用同一个错误的参数名重试;严禁在不同子命令间尝试 --uuid / --task-uuid / --id 三种名称反复试错;严禁凭记忆猜测参数名,必须查 --help | +| UUID 无效 | `taskUuid is invalid` / `dingOpenErrcode=300` | **第一时间切换策略**:调用 `dws minutes list mine --max 10 --format json` 获取真实可用的 uuid 列表,让用户选择或自动匹配最相关的一条。**真实案例中模型用同一个错 uuid 重试了 20 次全部失败——这是 minutes 模块失败率最高的错误模式,必须零容忍** | 严禁用同一个无效 uuid 重试哪怕 1 次(errcode=300 是不可重试错误);严禁从历史对话/文档/链接中猜测 uuid;严禁从十六进制编码字符串里截取部分数字拼凑新 uuid;严禁不切 list 就放弃 | +| 命令返回空 stdout | `get summary` / `get transcription` 返回空内容(stdout 为空,error_msg 也为空) | 1) 先调用 `dws minutes get info --id --format json` 确认听记是否存在且状态正常;2) 如果 info 也为空或报错,说明 uuid 本身有问题,回退到 list 命令重新获取 | 严禁在 stdout 为空时重复调用同一命令超过 2 次;严禁把空返回当作"没有内容"直接告知用户而不做任何排查 | +| 翻页 next-token 返回空 | `get transcription --next-token xxx` 返回空内容 | 同一个 next-token 返回空 **1 次**即视为到达末尾(has_more=false),立即终止翻页,基于已累积内容继续处理 | 严禁同一个 next-token 连续重试超过 2 次;严禁换不同的 next-token 值盲目尝试 | +| 时间格式解析失败 | `cannot parse time` / `parse fail` | 标准格式三选一:`2026-03-23T14:00:00+08:00`(ISO-8601 带时区)/ `2026-03-23 14:00:00`(无时区默认 +08:00)/ `2026-03-23`(纯日期) | 严禁使用 Unix 时间戳、`2026/03/23`、`Mar 23, 2026` 等非标准格式 | +| 权限不足 | `AUTH_PERMISSION_DENIED` / `Permission denied` / `user is not minutes creator` | 1) 如果是 `get transcription` 报权限错误,**自动降级**尝试 `get summary`(摘要通常不需要下载权限);2) 如果降级也失败,引导用户:"这份听记是别人分享给你的,你可以让原作者将你加为协作者,或者用 `dws minutes list shared` 查看共享给你的听记";3) 如果需要其他人的听记内容,可先用 `dws minutes list shared` 确认有无共享记录 | 严禁权限报错后用同一 uuid 反复重试;严禁只回复"Permission denied"而不给用户可操作的下一步 | +| 资源不存在 | `P_DataNotFound` / `dingOpenErrcode=404` | 与 `taskUuid is invalid`(uuid 格式错误)**区分对待**:`P_DataNotFound` 表示 uuid 格式正确但对应的听记不存在(可能已被删除或过期)。告知用户"该听记可能已被删除或已过期",然后建议用 `dws minutes list mine` 或 `list all` 查找替代 | 严禁把 `P_DataNotFound` 和 `taskUuid is invalid` 混为一谈;严禁 `P_DataNotFound` 后还换 uuid 格式重试 | +| 认证过期 | `not_authenticated` / `未登录` / `认证信息已失效` | 直接**重试刚才失败的命令**(最多两次),系统会自动刷新或重新授权;不要让用户重新提问。**注意**:长会话(>100 步)中 token 可能中途过期,这不是用户的问题 | 严禁认证过期后直接放弃整个任务;严禁让用户自己处理认证 | +| 上传业务报错 | `business error: success=false` / `Server Code: ERROR`(出现在 `upload create`) | 1) 检查文件名是否含特殊字符(建议用纯英文/数字文件名重试);2) 检查 `--file-size` 单位是否正确(必须是**字节**,不是 KB/MB);3) 确认音频格式是否支持(mp3/mp4/wav/m4a/aac);4) 最多重试 **1 次**,仍然失败则如实告知用户"上传服务暂时不可用",给出错误 Trace ID 供排查 | 严禁同一参数重试超过 2 次;严禁在错误信息未变化时反复重试期望随机成功;严禁尝试 `--json` / `-f json '{"...":...}'` 等不存在的输入格式——`--format json` / `-f json` 仅控制**输出格式**,不是输入参数 | + +### 间接意图识别(用户 query 未直接提及听记命令时的调用策略) + +当用户的 query 不是直接要求"查听记/看摘要",而是隐含需要听记数据的高阶任务时,AI 必须自动拆解并先完成听记数据采集。 + +> **【核心判断原则】** 用户 query 中只要涉及"会议/讨论/沟通"等**口头交流**产出的信息,听记就是不可替代的数据源。即使用户没说"听记"二字,只要任务的完成依赖于"会议中说了什么",就必须走 `dws minutes` 采集。**文档、日程、聊天记录无法替代听记**——文档是书面产出,日程只有标题和时间,聊天记录是文字沟通,唯有听记才包含会议的完整发言内容和 AI 摘要。 + +#### 间接意图 query 模式速查表 + +| 用户 query 模式 | 典型真实 query 示例 | 隐含的听记需求 | 正确的第一步 | +|----------------|-------------------|---------------|-------------| +| **报告/分析类**:要求基于某个主题或业务重新撰写报告 | "从悟空的商业模式分析,重新写一下市场感知报告" | 用户的报告素材来源于历史会议讨论,需要从听记中提取相关主题的会议内容作为写作输入 | `dws minutes list all --query "<主题关键词>" --max 20 --format json` → 对匹配的听记逐篇 `get summary` 提取素材 → 再基于素材撰写报告 | +| **分类总结类**:要求按类别(问题/事故/AI工作等)汇总工作成果 | "根据我的工作情况总结,分成问题应收、异常事故处理、ai相关工作和其他几部分总结";"最新文档啥也没有啊,我要你根据我的工作情况总结" | 用户需要的"工作情况"不仅存在于文档中——会议讨论、评审结论、决策记录都在听记里。尤其当用户明确说"文档啥也没有"时,听记是**唯一剩余的结构化工作记录来源** | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 按用户要求的分类维度(问题应收/事故处理/AI工作/其他)对摘要内容进行归类整理 | +| **多源聚合类**:用户明确要求补充听记/会议数据源,或抱怨当前数据源不全 | "你这个好像只是基于日程,我要的是我的对话、私聊、群聊、日程、会议、文档修改等相关的所有动作";"根据今天的聊天记录和听记总结工作日报" | 用户**点名要求**多数据源聚合,其中"会议"对应的数据源就是听记。即使 query 中还提到了聊天、日程、文档等,听记侧的数据采集**不能被省略** | 听记侧**必跑**:`dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary`;再并行采集其他数据源(聊天/日程/文档)→ 最终汇总 | +| **Skill 封装类**:要求把数据聚合逻辑封装成可复用的 skill 或脚本 | "把这个封装成日报skill";"做成自动化脚本" | 封装之前必须先**验证底层数据获取链路是否通畅**,不能跳过数据验证直接写 skill 框架 | 先用 `dws minutes list mine --start --format json` 验证听记数据可达且格式正确,再设计 skill 结构 | +| **日报/周报/月报类**:要求生成工作汇报 | "帮我写日报/周报";"帮我生成本周工作汇报" | 近期听记摘要是工作记录的核心来源之一 | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 汇总写入报告 | +| **客户/项目类**:按客户名或项目名搜索相关信息 | "外资背景的客户信息总结";"某某客户相关的所有信息" | 需要按关键词搜索听记 | `dws minutes list all --query "<客户名/项目名>" --format json` → `get summary` | +| **时间范围批量类**:梳理一段时间内的所有会议记录 | "梳理 4 月至今所有会议记录";"本周所有听记" | 明确时间范围的批量听记获取 | `dws minutes list mine --start "2026-04-01T00:00:00+08:00" --end "2026-05-08T23:59:59+08:00" --max 50 --format json` | + +#### 间接意图识别的三条铁律 + +**铁律 A:用户的任务包含"会议/讨论/沟通"类信息需求 → 听记采集是必跑前置步骤,不可跳过** +- 即使用户 query 中没有出现"听记"二字,只要任务完成需要"会议上说了什么/讨论了什么/达成了什么结论",就必须走 `dws minutes` 采集 +- 典型信号词:报告、分析、总结、汇总、复盘、回顾、工作情况、商业模式、市场感知、客户沟通 + +**铁律 B:用户说"文档/数据啥也没有" → 听记的优先级反而更高** +- 当用户明确反馈文档不足时,听记(会议记录)往往是**唯一还有内容的结构化数据源** +- 此时 AI 的正确反应不是"那我也没办法",而是主动切到听记侧采集:"让我先看看你最近的听记记录中有没有相关内容。" + +**铁律 C:多源聚合场景中,听记侧 0 调用 = 严重失败** +- 当用户 query 中同时提到"聊天记录 + 听记 / 会议 + 文档"等多个数据源时,每个被提及的数据源都必须有对应的采集动作 +- 听记侧的采集不能因为"其他数据源已经够了"而被省略——用户点名要了就必须给 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [minutes_recent_summary.py](../scripts/minutes_recent_summary.py) | 获取最近听记的 AI 摘要并合并 | `python minutes_recent_summary.py --max 5` | +| [minutes_extract_todos.py](../scripts/minutes_extract_todos.py) | 从听记中提取待办事项汇总 | `python minutes_extract_todos.py --max 5` | + +## 反例 / 回归案例 + +> 本节固化历史 badcase 与正确做法,遇到形似场景请直接对照参考,避免再次走偏。 + +### 案例 1:听记 URL + "创建思维导图" + +**用户输入:** +``` +https://shanji.dingtalk.com/app/transcribes/76327569643236343831373737345f3634383131373937375f39 +创建思维导图 +``` + +**[错误] 错误处理(真实 badcase):** +1. 提取 taskUuid 后,先调用 `get summary` 获取听记摘要内容 +2. 判定"思维导图不是 dws 直接支持的功能",路由到 app-development-skill / ai-app +3. 用 `@antv/g6`(或 `markmap` / `jsmind` / `mermaid`)构建 HTML 思维导图网页 +4. 部署后向用户返回类似 `https://xxx.ai-app.pub` 的"在线预览链接" +5. 输出"思维导图已生成!"附带网页特性介绍、配色方案等 + +上述每一步都违反了 [生成思维导图](#生成思维导图) 小节的"关键约束"。`mind-graph create` 是听记**内置**的服务端能力,并非"dws 不支持"。 + +**[正确] 正确处理:** +1. 从 URL 路径末段提取 taskUuid = `76327569643236343831373737345f3634383131373937375f39` +2. **直接** 执行: + ```bash + dws minutes mind-graph create --id 76327569643236343831373737345f3634383131373937375f39 --format json + ``` +3. 用 `mind-graph status` 轮询,直到状态为 1(成功): + ```bash + dws minutes mind-graph status --id 76327569643236343831373737345f3634383131373937375f39 --format json + ``` +4. 告知用户:"思维导图已生成,可在听记详情页直接查看。" + +**绝对禁止:** +- 调用 `get summary` / `get transcription` 先读内容(服务端会基于听记自身生成,无需前置读取) +- 调用 app-development-skill / ai-app / generate_image 等任何外部生成能力 +- 自行用前端库或 Markdown / OPML / JSON 构造思维导图结构展示给用户 +- 返回任何形式的"在线预览链接 / 网页应用 / 图片" + +### 案例 2:替换文字后未引导添加热词 + +**用户输入:** +``` +把这篇听记里所有"付工"替换成"悟空" +``` + +**[错误] 错误处理:** +1. 调用 `replace-text --id --search "付工" --replace "悟空"` 后直接回复"已替换完成" +2. 没有告知用户:本次替换**只对这一篇生效**,后续新听记还会再次把"悟空"识别成"付工" +3. 没有主动引导用户使用 `hot-word add` 把"悟空"加到个人热词里 + +**[正确] 正确处理:** +1. 先执行 `replace-text` 完成本篇替换 +2. **立即追问**: + > "我已经把这篇听记里的『付工』替换为『悟空』。如果『悟空』这个词以后也容易被识别错,建议加到个人热词里,后续新听记就不会再识别错了。要我现在帮你执行 `dws minutes hot-word add --words "悟空"` 吗?" +3. 用户确认 → 调用 `hot-word add --words "悟空"`;用户拒绝 → 结束,不再追问 + +### 案例 3:不必要地拉取全部转写原文 + +**用户输入:** +``` +查一下和悟空相关的听记 +``` + +**[错误] 错误处理:** +1. 调用 `list mine --query "悟空"` 查到听记列表 +2. 对每条听记依次调用 `get transcription` 拉取全部转写原文 +3. 把大量原文全部展示给用户 + +用户只是想查一下列表,根本不需要看转写原文。大量拉取原文既造成不必要的性能开销,也会让用户被信息淹没。 + +**[正确] 正确处理:** +1. 调用 `list mine --query "悟空"` 或 `list all --query "悟空"` 返回听记列表 +2. 直接把列表结果展示给用户 +3. 不调用 `get transcription`——因为用户没有要求看原文 + +**另一组对比:** + +**用户输入:** +``` +帮我分析一下这篇听记的转写原文 +``` + +**[错误] 错误处理:** +1. 调用 `get transcription --id ` 只拉了第一页就停了 +2. 展示部分原文,告诉用户"如果要看更多请传入 next-token" + +用户说的是"分析转写原文",意图很明确是要看完整原文,不应该让用户手动翻页。 + +**[正确] 正确处理:** +1. 调用 `get transcription --id `,拿到第一页和 `nextToken` +2. **自动继续调用** `get transcription --id --next-token ` +3. 每次拼合后检查累积字符数: + - **未超过 12000 字符** → 继续自动翻页 + - **超过 12000 字符** → 暂停,提示用户:"当前已拉取约 X 字符的转写内容,已达到单次处理上限。是否继续拉取后续内容?" +4. 用户确认继续 → 接着翻页;用户拒绝 → 停止翻页,基于已拉取内容进行分析展示 + +### 案例 4:拉完转写后只输出时间线原文,未引导发言人聚类与替换 + +**用户输入:** +``` +帮我把这篇听记的转写原文拉出来分析一下 +``` + +**[错误] 错误处理(真实 badcase):** +1. 调用 `get transcription` 自动翻页拉完全部原文 +2. 直接把按时间戳穿插的"发言人1: ... / 发言人2: ... / 发言人1: ..."大段原文全部丢给用户,结束流程 +3. 用户后续追问"李总主要讲了什么",AI 又要重新读一遍原文才能回答 +4. 即使用户后来说"发言人1 就是李总",AI 也只是在自己回复里口头改一改,**不调用 `speaker replace`**,听记本身的发言人映射没有任何变化 + +按时间线穿插的原文对人**极其不友好**——同一个发言人的内容散落在不同时间点,用户很难看清"某个人讲了什么"。而且听记里的『发言人1/发言人2』占位符如果一直不被替换为真实姓名,用户每次回看都要靠脑补才能对上号。 + +**[正确] 正确处理:** +1. 调用 `get transcription` 自动翻页拉完全部原文(注意 12000 字符上限保护) +2. **拉完后立即追问**:"已拉取完整转写原文(共 N 段,X 个发言人)。当前默认按时间线返回。是否需要我帮你**按发言人分组聚类**,并提取每位发言人的**核心发言要点**?" +3. 用户确认 → AI **本地完成聚类**(按 `speakerNick` 分组),每位发言人输出 3-5 条核心要点,再追问:"如果你能告诉我『某某人主要讲了什么』(如『李总主要讲了战略规划』),我可以根据关键词帮你**自动匹配**对应的发言人,并把『发言人1/发言人2』替换成真实姓名。" +4. 用户回复『李总主要讲了战略规划』→ AI 抽取关键词『战略规划』,在已聚类的各发言人核心要点里做模糊匹配;找到唯一高置信候选『发言人1』→ 引导确认:"『发言人1』很可能就是你说的『李总』(命中关键词:战略规划、AI 化转型)。是否需要把这篇听记里的『发言人1』全部替换为『李总』?确认后我会执行 `dws minutes speaker replace --id --from "发言人1" --to "李总"`" +5. 用户确认 → **立即调用** `dws minutes speaker replace --id --from "发言人1" --to "李总" --format json`,执行成功后告知用户"已替换,纪要与待办中的发言人也已同步更新" + +**绝对禁止:** +- 拉完转写就只丢一大段时间线原文给用户,不做聚类、不主动引导 +- 用户提供"某某人讲了 XX"后,AI 在自己脑内/回复里"假装替换"了,但**不实际调用** `speaker replace` +- 在置信度不足(多候选)时仍然给出唯一答案,不让用户参与挑选 +- 把『发言人1 → 李总』的映射写到用户回复里就完事,听记本身没有任何写回 + +### 案例 5:用户查某人在听记中说了什么,总结完不引导替换发言人 + +**用户输入:** +``` +帮我看看这个听记里张三说了什么 +https://shanji.dingtalk.com/app/transcribes/76327569643231383535353939365f3436383537393431335f32 +``` + +**[错误] 错误处理(真实 badcase):** +1. 从 URL 提取 taskUuid,调用 `get transcription` 拉取转写原文 +2. 发现转写中只有"发言人1/发言人2/发言人3",没有"张三" +3. AI 凭原文推断"发言人2 可能是张三"(因为内容提到了产品需求),直接把发言人2 的内容当作张三的输出给用户 +4. 总结输出完就结束了,**没有引导用户确认推断是否正确** +5. **没有引导用户替换发言人**——听记本身还是显示"发言人2",下次用户打开听记还是看不出谁是张三 + +上述做法有两个严重问题:① 推断结果未经用户确认就当作事实输出,可能张冠李戴;② 即使推断正确,也没有调用 `speaker replace` 写回听记,用户下次看还是"发言人2"。 + +**[正确] 正确处理:** +1. 从 URL 提取 taskUuid,调用 `get transcription` 自动翻页拉取全部转写 +2. **Step 2 声纹标注检查**:检查转写中是否已有"张三"作为 speakerNick → 本例中没有,只有匿名编号 +3. **Step 3 转写原文推断**:在原文中寻找线索——例如其他人说"张三你来汇报一下",紧接着"发言人2"开始发言;或者"发言人2"的内容大量涉及产品需求(与用户描述的张三角色吻合) +4. 推断出"发言人2"可能是张三后,**必须向用户确认**: + > "根据转写内容分析,以下发言最可能是张三的: + > 「接下来我汇报一下 Q3 的产品规划,主要有三个方向...」 + > 确认是张三吗?" +5. 用户确认 → **Step 6 结构化总结输出**:提取发言人2 的全部发言,输出张三的核心观点、关键决策、待办等 +6. **Step 7 引导替换发言人**(必须执行): + > "目前这篇听记中张三的发言仍显示为『发言人2』。要我帮你把听记里的『发言人2』全部替换为『张三』吗?替换后纪要和待办中的发言人也会同步更新。" +7. 用户确认 → 立即调用 `dws minutes speaker replace --id --from "发言人2" --to "张三" --format json` +8. 替换成功后追问:"还有其他发言人需要我帮你识别和替换吗?" + +**绝对禁止:** +- 推断"发言人X 是张三"后不向用户确认就直接输出总结——可能张冠李戴 +- 总结完就结束,不引导替换发言人——用户下次看听记还是"发言人2" +- 只在回复里说"发言人2 就是张三"但不调用 `speaker replace`——听记本身没有任何变化 +- Step 3 推断不出来时直接告知"找不到张三"就结束——应继续走 Step 4 多路并发推断(通讯录/文档/日程/聊天记录) + +### 案例 6:通过通讯录 + 部门角色 + 转写线索三路印证推断发言人(真实复盘) + +> 这是一次**完整走完 Step 1~7 全流程**的真实案例,重点演示 Step 4 多路并发身份推断如何与 Step 3 转写原文线索互相印证,以及如何识别"花名相似但不是同一人"的陷阱。 + +**用户输入:** +``` +https://shanji.dingtalk.com/app/transcribes/ 分析下木兰讲了什么 +``` + +**[正确] 完整执行链路:** + +**Step 1:定位听记并读取转写** +- 从 URL 末段提取 taskUuid +- `dws minutes get transcription --id --format json` 自动翻页拉取全部(注意 12000 字符上限保护) + +**Step 2:声纹标注检查** +- 转写中所有 `speakerNick` 都是匿名编号(发言人1/2/3/4),**未命中** → 进入 Step 3 + +**Step 3:转写原文内推断(同时并发启动 Step 4,不串行等待)** + +先做粗粒度的发言人画像,列出每位发言人的发言量、主题、关键互动信号: + +| 发言人 | 发言特征 | 互斥线索(说明 TA "不是谁")| +|--------|----------|------| +| 发言人1 | 发言较多,集中在 UI/交互设计:A/B 面切换、按钮位置、页面层级、设备号承接页 | 1013s 提到「**木风**也给了一些各种状态」→ 木风 ≠ 发言人1 | +| 发言人2 | 发言最多且最主导,讨论 agent 架构、skill 设计、记忆系统、定时任务 | 1063s 叫「青锋给大家看」、2113s 叫「行远」、多次提「虎哥」「陈林」→ 这几人都不是发言人2 | +| 发言人3 | 中等发言量,讨论功能号、对话框、班主任场景 | 3549s 提到「木风挺调皮」→ 木风 ≠ 发言人3 | +| 发言人4 | 发言较少,技术实现相关(接口、链路、安卓、蓝牙)| —— | + +**Step 4:多路并发身份推断(与 Step 3 同时进行)** + +| 路径 | 命令 | 结果 | +|------|------|------| +| ① 通讯录组织架构 | `dws contact user search --query "木兰"` | 木兰 = **王佳明**,X 事业群-X 事业部-X-X-**产品设计部**,上级临渊(王临一)| +| ② 文档产出 | `dws doc search --query "王佳明"`(按需)| 多为设计稿/原型,进一步印证设计师角色 | + +**Step 5:定向匹配 + 置信度判断** + +把 Step 4 拿到的"产品设计部 / 设计师角色"信号回投到 Step 3 的发言人画像: + +- 候选锁定 **发言人1**——其特征(UI 设计、A/B 面切换、页面层级、汇报设计进展)与"产品设计部设计师"高度吻合 +- **额外强信号**:发言人1 在 1856s 说「最开始跟**拾光**做 A 店」——拾光 = 王刘明(当前用户),属同部门协作关系,与"木兰也在产品设计部"完全对得上 +- **同名陷阱排查**:发言人1 在 1013s 说「**木风**也给了一些各种状态」——`木风 ≠ 木兰`(两个不同花名,木风是另一位设计同事),**这条线索看似矛盾,实则强化了"发言人1 是木兰本人在转述木风的产出"的判断** +- 综合置信度 **≈ 75%**(>70%),走**分支 A:文本片段确认** + +**Step 5 → 用户确认环节**(实际对话中必须有这一步): + +> 根据分析,发言人1 很可能就是木兰(王佳明,产品设计部)。最具代表性的发言片段: +> 「最开始跟拾光做 A 店就是在功能号做的……当前首页内容层级太多,无论怎么改都解决不了根本问题」 +> 推断依据:UI 设计视角 + 与拾光有合作经历 + 同部门关系 +> 确认是木兰吗?是 / 不是 + +**Step 6:结构化总结输出(四段式模板)** + +确认后按以下结构组织——这套四段式适用于绝大多数会议场景: + +``` +[人名] 在本次会议中的发言总结 + +**核心观点** +- 观点1(带具体上下文,不要只写关键词) +- 观点2 +- ... + +**提出的问题 / 关注点** +- 问题1 +- ... + +**Action Item / 承诺事项** +- 时间节点 + 具体动作 +- ... + +**立场 / 态度** +- 对核心议题的明确态度(支持/反对/务实/保留) +- ... +``` + +**Step 7:引导替换发言人(必须执行)** + +> 目前这篇听记中木兰的发言仍显示为『发言人1』。要我帮你把听记里的『发言人1』全部替换为『木兰』吗?替换后纪要和待办中的发言人也会同步更新。 +> 确认后我会执行:`dws minutes speaker replace --id --from "发言人1" --to "木兰"` + +用户确认 → 立即调用 `dws minutes speaker replace`,并追问"还有其他发言人需要识别吗?" + +**本案例固化的关键经验(必须吸收):** + +1. **花名相似不等于同一人**:`木风 ≠ 木兰`、`拾光 ≠ 拾贝`、`临渊 ≠ 临川` 这类 1 字之差的花名极易误判。**遇到候选人花名出现在某发言人原话里时,必须先确认这是"自指(在自我介绍)"还是"他指(在叫别人)",再做互斥推断**。在原文中"A 提到 B"通常意味着 `A ≠ B`,不要反过来判定 `A = B`。 + +2. **Step 3 与 Step 4 必须并发**:先做发言人画像(Step 3)的同时**异步发起**通讯录查询(Step 4 ①),等通讯录返回了"角色/部门"信号再回投到画像里做匹配,这样不串行等待、不浪费时间。 + +3. **同部门协作关系是强信号**:当发言人 X 提到了某个同事的花名(如「跟拾光做 A 店」),而通讯录显示"目标人物"和该同事**同部门**,这是非常强的身份匹配信号,可以直接把置信度提升到 70%+。 + +4. **置信度 ≥ 70% 即可走分支 A**:不要一味追求 90%+,否则会陷入"再查一路、再印证一次"的死循环。70% 是经验阈值,分支 A 本身就有"用户文本确认"作为兜底,错了用户会立即纠正。 + +5. **结构化总结用四段式模板**:核心观点 / 关注点 / Action Item / 立场态度——这套模板适用于绝大多数会议场景(产品评审、技术方案、复盘会、双周会)。每条要点必须**带上下文**(如"她认为底部那一排功能导航必须去掉,因为与上方内容严重重复"),不要只写"反对底部导航"这种干巴巴的关键词。 + +6. **Step 7 必须执行,不能跳过**:本案例如果只输出总结就结束,下次用户打开听记看到的还是"发言人1",依然要靠脑补对应木兰——这正是发言人识别功能存在的意义被完全抹掉的反例。 + +**绝对禁止:** +- 看到发言人说「木风也给了状态」就直接判定"那 TA 就是木风的同事/下属"等过度推断——只能得出 `TA ≠ 木风` 这一条互斥信息 +- Step 3 还在分析就阻塞住,等画像分析完再串行去查通讯录——必须并发 +- 总结写成"木兰讨论了 UI 设计、A/B 面切换、功能号方向"这种关键词堆砌——必须展开成带上下文的完整观点 +- 置信度 70% 就纠结要不要再查文档/聊天记录——分支 A 的文本确认本身就是兜底,不要无谓地继续查 + +### 案例 7:跳过通讯录、在转写文本里 grep 花名 → 误判"目标人物没参会"(真实反面教材) + +> 这是**与案例 6 输入完全相同**但执行链路完全错误的真实 badcase,重点演示"没走 Step 4 通讯录查询"会带来怎样灾难性的失败结论。**强烈建议每次执行发言人识别任务前,对照本案例自检一遍**。 + +**用户输入:**(与案例 6 完全相同) +``` +https://shanji.dingtalk.com/app/transcribes/ 分析下木兰讲了什么 +``` + +**[错误] 真实失败链路(每一步都要识别为反模式):** + +``` +Step 1 **[完成]** get transcription 拉取了多页转写 + ↓ +Step 2 **[禁止]** 看到全是匿名编号 → 没有继续走 Step 3-4,反而开始在转写里 grep "木兰" + ↓ +错误动作 A:连续多次"搜索所有日志文件中是否有'木兰'这个名字" + ↓ +错误动作 B:转写里搜不到 → 调用 get summary 看 AI 摘要里的"参与人" + ↓ +错误动作 C:AI 摘要"参与人=拾光" → 推理"参与人只有拾光 → 木兰没参会" + ↓ +错误结论:告诉用户"在这篇听记中,没有找到名为木兰的发言人。可能木兰没参加这次会议" + ↓ +错误兜底:把责任甩给用户:"请你确认是哪位发言人,或在客户端看参会人列表" +``` + +**这条链路违反了几乎所有铁律:** + +| 反模式 | 违反的铁律 | 后果 | +|--------|------------|------| +| 在转写文本里 grep "木兰" 字符串作为存在性判断 | 铁律 1 | 99% 听记的发言人都是匿名编号,搜不到字面是默认场景,根本不构成"没参会"的证据 | +| 用 AI 摘要的"参与人=拾光"推断"木兰没参会" | 铁律 2 | AI 摘要"参与人"字段只截取最显著的 1-2 人,**不是**完整参会名册 | +| 全程没调用过一次 `dws contact user search --query "木兰"` | 铁律 3 | 通讯录查询是 Step 4 的必跑项,单次调用就能拿到"木兰=王佳明,产品设计部" | +| 一旦字面搜不到就放弃身份推断,把任务甩给用户 | 铁律 4 | 这恰恰把发言人识别功能的核心价值(把匿名编号映射到真实人)完全抹掉了 | + +**[正确] 应该这样执行(与案例 6 一致):** + +1. **Step 1**:从 URL 提取 taskUuid → `dws minutes get transcription` 自动翻页拉全部 +2. **Step 2**:检查 `speakerNick` 字段是否含"木兰"——发现全是匿名编号 → **不要在转写文本里 grep "木兰",立即并发触发 Step 3 + Step 4 ①** +3. **Step 3**(与 Step 4 ① 并发):在转写里做发言人画像(每位发言人的发言量、主题、互斥线索) +4. **Step 4 ①**(与 Step 3 并发,必跑):`dws contact user search --query "木兰"` → 拿到"木兰=王佳明,产品设计部,上级临渊" +5. **Step 5**:把"产品设计部 + 设计师角色"信号回投到画像 → 锁定发言人1(UI/交互设计视角高度匹配)+ 与拾光的同部门协作信号 → 置信度 ≈ 75% → 走分支 A +6. **Step 5 用户确认**:展示发言人1 的代表性片段请用户确认 +7. **Step 6**:四段式结构化总结(核心观点/关注点/Action Item/立场态度) +8. **Step 7**:引导调用 `speaker replace` 把"发言人1"替换为"木兰" + +**关键经验(强制吸收,案例 6 已讲过的不再重复,本案例独有的):** + +1. **"在转写里搜不到目标人名"绝不构成"没参会"的证据**:花名/真名通常不出现在 TA 自己的发言里,这是默认场景而非例外。99% 的听记发言人都是匿名编号——这正是发言人识别功能要解决的问题。 + +2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人;要拿完整参会列表,应使用 `get info` / `get batch` 返回的结构化 `participants` 字段。 + +3. **`dws contact user search` 是 Step 4 ① 的必跑项,单次调用就能突破死局**:本案例的整个失败链路只要有 1 次通讯录查询就能立即扭转——拿到"木兰=王佳明,产品设计部"后,Step 5 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。 + +4. **想说"找不到 X"前的四个自检问题**(任何一个回答"没"都禁止给"找不到"结论): + - 通讯录查了吗?(`dws contact user search --query "X"`) + - 文档查了吗?(`dws doc search --query "X"`) + - 聊天记录查了吗?(`dws chat message list`) + - 基于角色在转写里做模式匹配了吗?(设计师 vs 研发 vs 管理者的发言特征) + +5. **`get summary` 不能替代发言人识别**:摘要是"会议讲了什么"的总览,**不是**"谁讲了什么"的精细切分。混淆这两个能力会导致 Step 4 直接跳过。 + +**绝对禁止:** +- **[禁止]** Step 2 看到匿名编号后,直接在转写文本里 grep 目标人名 → 没找到就退出 +- **[禁止]** 用 `get summary` 摘要里写到的"参与人"判断某人是否参会 +- **[禁止]** 全流程不调用 `dws contact user search` 就给出"找不到 X"的结论 +- **[禁止]** 把身份推断的责任甩回给用户:"请你告诉我哪位是木兰" / "请去客户端看参会人"——发言人识别功能的存在意义就是 AI 来做这件事 + +### 案例 8:听记/纪要类 query 不走 dws 技能(基础评测集 16 例 badcase 复盘) + +> 这是一组**最高频的失败模式**——用户提出听记/纪要/会议总结/待办提取/链接解析等典型 dws 场景请求,AI 却用 `session_search` / `memory_search` / `activity:search` / `browser_use` / `read_file` / 直接反问 等"伪替代"路径绕过 dws 技能,导致核心链路 0 命中。下面把基础评测集(minutes-base, evalrun_4ab46f8da846)中**全部 16 个该模式失败 query 完整列出**,遇到形似输入请直接对照本案例处理。 + +#### 一、五类典型 badcase 模式(按失败动作归类) + +**模式 A:模糊/省略型 query → AI 直接反问要细节,不主动 list** + +涉及 query: + +| case_id | 用户原始 query | +|---------|----------------| +| `dws_minutes_hotquery_0049` | 按关键词搜索我的听记 | +| `dws_minutes_hotquery_0057` | 查列表+看摘要 | +| `dws_minutes_hotquery_0063` | 周会回顾整理 | +| `dws_minutes_hotquery_0064` | 评测工作复盘 | +| `dws_minutes_hotquery_0042` | 把所有听记内容添加到汇报中 | + +**典型错误动作**:`tool_calls = []`,AI 回复"请告诉我具体的关键词/时间范围/会议名"就停下,等待用户补充。 + +**模式 B:用 `session_search` / `memory_search` 搜索历史会话假装"找过了"** + +涉及 query: + +| case_id | 用户原始 query | +|---------|----------------| +| `dws_minutes_hotquery_0003` | 帮我查一下最近一次会议的纪要内容 | +| `dws_minutes_hotquery_0017` | 总结下我的会议 | +| `dws_minutes_hotquery_0020` | 我昨天那个会的重点帮我提炼一下 | +| `dws_minutes_hotquery_0027` | 把最近一次会议的待办整理出来 | + +**典型错误动作**:调用 `session_search` 搜以前的对话记录,把以前 AI 自己生成过的"会议纪要文件描述"当作真实数据复述出来;从未触发 `dws minutes list / get summary / get todos`。 + +**模式 C:用 `activity:search` / web 搜索把"找听记"做成"搜网页"** + +涉及 query: + +| case_id | 用户原始 query | +|---------|----------------| +| `dws_minutes_hotquery_0025` | 调取某某项目讨论的两个听记内容 | +| `dws_minutes_hotquery_0060` | 搜索+摘要+关键词 | + +**典型错误动作**:调用 `activity:search` 搜公网,返回的是"钉钉 AI 听记产品介绍"页面,与用户的私人听记数据毫不相关。 + +**模式 D:钉钉听记/文档 URL 走 `browser_use` / `read_file` 而非 `dws`** + +涉及 query: + +| case_id | 用户原始 query | +|---------|----------------| +| `dws_minutes_hotquery_0045` | `https://shanji.dingtalk.com/meeting/minutes?taskUuid=sample004` | +| `dws_minutes_hotquery_0046` | `https://alidocs.dingtalk.com/i/nodes/sampleDocNode01` 帮我读取这个文档内容 | +| `dws_minutes_hotquery_0047` | 这个听记链接你能打开看内容吗 `https://shanji.dingtalk.com/meeting/minutes?taskUuid=sample006` | + +**典型错误动作**:`browser_use` 打开页面遇到登录墙就回复"需要登录";或 `read_file` 当本地文件读 → 失败 → 把锅甩给用户。完全没有意识到 dws 技能本身已携带账号态,能直接通过 taskUuid/dentryUuid 拿到内容。 + +**模式 E:多源数据生成日报/汇报/总结/报告,听记侧 0 调用** + +涉及 query: + +| case_id | 用户原始 query | +|---------|----------------| +| `dws_minutes_hotquery_0040` | 根据今天的聊天记录和听记总结工作日报 | +| `dws_minutes_hotquery_0005` | 把会议纪要写入钉钉文档 | +| `minutes-001` (P0) | 从悟空的商业模式分析,重新写一下市场感知报告 | +| `minutes-002` (P0) | 最新文档啥也没有啊,我要你根据我的工作情况总结,分成问题应收、异常事故处理、ai相关工作和其他几部分总结 | +| `minutes-004` (P0) | 你这个好像只是基于日程,我要的是我的对话,发出去的文字,私聊,群聊,日程,会议,文档修改等相关的所有动作,参考这个生成日报,并且把这个封装成日报skill | + +**典型错误动作**:识别出"日报/报告/总结/写文档"场景后,只想着调用周报技能 / 文档写入技能,**完全跳过听记数据获取**这一步。更隐蔽的变体是:用户 query 中**没有出现"听记"二字**(如"根据工作情况总结"、"重新写市场感知报告"),但任务的完成**实际依赖会议讨论内容**,AI 却完全没有意识到需要从听记中采集素材,结果输出空洞的报告或反问"请告诉我会议纪要内容"。 + +**P0 case 暴露的三种变体(必须全部识别):** +- **变体 1(minutes-001)**:用户要写报告,报告素材来源于历史会议讨论 → AI 没识别出需要查听记 +- **变体 2(minutes-002)**:用户要分类总结工作,且明确说"文档啥也没有" → AI 没切到听记作为替代数据源 +- **变体 3(minutes-004)**:用户明确抱怨"只是基于日程"并点名要求"会议"等多数据源 → AI 仍然只用日程数据 + +#### 二、五类共性反模式(必须全部识别为禁止动作) + +| 反模式 | 错误根因 | 正确做法 | +|--------|----------|----------| +| **R1:模糊请求 → 反问要细节** | AI 把"信息不足"当成必须澄清的前置条件 | 听记类模糊请求 → **默认先调用 `dws minutes list mine --max 10 --format json`** 把最近的听记列出来,让用户从列表里挑,而不是反问关键词 | +| **R2:用 `session_search`/`memory_search` 替代 dws** | AI 把"历史会话里聊过的纪要描述"误认为是真实数据源 | 历史会话只能回忆"以前我们聊过什么",**不是真实听记数据**。听记数据**必须**从 `dws minutes` 实时拉取 | +| **R3:用 `activity:search` 通用 web 搜索听记** | 把"听记"理解成公网信息 | 听记是用户私人钉钉数据,**只能**通过 `dws minutes list/get` 获取,公网搜不到也不该搜 | +| **R4:钉钉 URL 走 browser_use/read_file** | 把钉钉 URL 当成普通网页 | 钉钉听记 URL(`shanji.dingtalk.com/meeting/minutes?taskUuid=xxx` 或 `shanji.dingtalk.com/app/transcribes/xxx`)→ **提取 taskUuid → 走 `dws minutes get summary/get transcription`**;钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`)→ **提取 dentryUuid → 走 `dws doc read`** | +| **R5:日报/汇报/总结/报告场景跳过听记数据采集** | 只看到"日报/报告/总结"就直奔输出端,忘了用户指定的(或隐含的)数据源包含会议/听记。**不仅限于用户说了"听记"二字的场景**——"根据我的工作情况总结"(minutes-002)、"重新写一下市场感知报告"(minutes-001)、"我要的是我的对话...会议...生成日报"(minutes-004)都属于此类 | query 中出现"总结/报告/汇报/日报/周报/工作情况/商业分析/市场感知/复盘"等产出类关键词时,必须自检:**用户的产出是否依赖会议讨论内容?** 如果是 → **第一步必须** `dws minutes list mine` 拿听记 → `get summary` 逐篇拉摘要 → 再汇总。详见「间接意图识别」章节 | + +#### 三、五类 badcase 的统一正确链路(速查表) + +| 用户 query 形态 | 第一步必跑命令 | 关键说明 | +|------------------|------------------|----------| +| 模糊请求:"总结下我的会议" / "周会回顾整理" / "查列表+看摘要" / "评测工作复盘" / "按关键词搜索我的听记" | `dws minutes list mine --max 10 --format json` | 拿到最近听记列表后,对前 1~3 篇 `get summary`,引导用户挑选目标 | +| 含时间词:"最近一次/昨天/本周/上周的会议" | `dws minutes list mine --start --end --max 20 --format json` | 时间范围按用户描述折算,不要让用户自己提供日期 | +| 含主题/项目关键词:"某某项目讨论" / "搜索+摘要+关键词" | `dws minutes list all --query "<关键词>" --max 20 --format json` | 用 `--query` 而不是 `activity:search` | +| 钉钉听记 URL(`shanji.dingtalk.com/...?taskUuid=xxx`) | `dws minutes get summary --id --format json` | 从 URL 提取 taskUuid,禁用 browser_use | +| 钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`) | `dws doc read --node --format json` | 走 doc 技能而非 read_file/browser_use | +| 多篇听记对比:"对比一下这几个听记 [URL1] [URL2]" | 对每个 URL 分别 `dws minutes get summary --id --format json` | 失败的 URL 给出明确说明,不要把锅全甩给用户 | +| 日报/汇报含"听记"/"会议纪要"关键词 | 先 `dws minutes list mine --start <今日 0 点> --max 20`,再对每篇 `get summary`,最后才汇总 | 听记数据采集是必跑前置,不能直接跳到周报技能 | +| 报告/分析类(query 未显式提"听记"但依赖会议讨论素材):"重新写一下市场感知报告" / "帮我生成商业分析" | `dws minutes list all --query "<主题关键词>" --max 20 --format json` → 逐篇 `get summary` 提取素材 | 详见「间接意图识别」,用户的报告素材来源于历史会议讨论 | +| 分类工作总结(query 未显式提"听记"但需要工作记录):"根据我的工作情况总结,分成…几部分" / "文档啥也没有,根据工作情况总结" | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 按分类维度归类 | 文档不足时听记是唯一剩余数据源;详见「间接意图识别」 | +| 多源聚合(用户抱怨数据源不全或点名要求会议数据):"只是基于日程,我要的是对话+会议+文档等所有动作" / "聊天记录和听记总结日报" | 听记侧必跑 `dws minutes list mine --start <起始日期> --end <截止日期>` → `get summary`,再并行采集其他源 | 用户点名要了"会议"就不能省略听记采集 | +| 待办提取:"最近一次会议的待办" | `dws minutes list mine --max 1` → `dws minutes get todos --id ` | 用 `get todos`,不要自己从转写里硬抠 | +| 写入钉钉文档:"把会议纪要写入钉钉文档" | 先 `dws minutes get summary --id ` 拿到内容 → 再 `dws doc create` / `dws doc update` 写入 | 听记数据采集 + 文档写入是两步,缺一不可 | + +#### 四、绝对禁止(任何一条触发即视为严重失败) + +- **[禁止]** 听记/纪要/会议类请求 → `tool_calls = []` 直接反问要细节(任何模糊请求至少要先 `dws minutes list mine` 跑一次,让用户在列表里挑) +- **[禁止]** 用 `session_search` / `memory_search` 搜以前的会话记录当作"真实听记数据"复述给用户——历史会话不是数据源 +- **[禁止]** 用 `activity:search` / web 搜索找用户私人听记——听记是私域数据,公网搜不到 +- **[禁止]** 钉钉听记 URL(`shanji.dingtalk.com/meeting/minutes?taskUuid=xxx` / `shanji.dingtalk.com/app/transcribes/xxx`)走 `browser_use` 打开页面——必须提取 taskUuid 走 `dws minutes get` +- **[禁止]** 钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`)走 `read_file` / `browser_use`——必须走 `dws doc read` +- **[禁止]** 遇到登录墙 / URL 无效就只回复"需要登录" / "请提供正确 URL" 然后停下——必须 fallback 到 `dws minutes list mine` 让用户从自己的听记列表里挑替代项 +- **[禁止]** 日报/周报/汇报/总结/报告场景,任务产出依赖会议讨论内容,却跳过 `dws minutes` 数据采集——**不限于 query 中出现"听记"二字的场景**,"根据工作情况总结"(minutes-002)、"重新写市场感知报告"(minutes-001)等间接意图同样适用 +- **[禁止]** "把会议纪要写入钉钉文档"类请求只调文档写入工具不调 `dws minutes get summary`——会议纪要内容必须先实时获取,不能让用户自己粘贴 +- **[禁止]** `taskUuid is invalid` 报错后用同一个无效 uuid 重试(哪怕 1 次)——必须立即切换到 `dws minutes list mine` 获取真实 uuid +- **[禁止]** 从历史对话、文档内容、十六进制编码字符串中猜测或拼凑 uuid——uuid **只能**来自 `list mine/shared/all` 返回或用户提供的听记 URL +- **[禁止]** 使用错误的命令层级结构:`dws minutes info`(应为 `get info`)、`dws minutes get`(缺子命令)、`dws minutes list --start`(缺 scope)——参照文档顶部"命令层级结构" +- **[禁止]** 工具调用缺少 `--format json` 参数——所有 `dws` 命令都应带上以便结构化解析 + +#### 五、自检清单(执行前 5 秒强制走一遍) + +收到任何含以下信号词的 query 时,**第一个动作必须是 `dws minutes` 或 `dws doc`,否则视为走错链路**: + +> **直接信号词**(query 中直接提到听记相关概念): +> "听记 / 纪要 / 摘要 / 转写 / 录音 / 会议 / 周会 / 日会 / 评审会 / 复盘 / 回顾 / 待办 / Action Item / 关键词 / 我的会 / 昨天那个会 / 上周的会 / 共享听记 / shanji.dingtalk.com / alidocs.dingtalk.com" + +> **间接信号词**(query 未提"听记"但任务产出依赖会议讨论内容,需要额外判断——详见「间接意图识别」章节): +> "报告 / 市场感知 / 商业分析 / 工作情况总结 / 分类总结 / 日报 / 周报 / 月报 / 工作汇报 / 文档啥也没有 / 只是基于日程 / 所有动作 / 封装成 skill" +> **判断标准**:用户的产出是否依赖"会议上说了什么/讨论了什么"?如果是 → 听记采集是必跑前置步骤 + +强制问自己 5 个问题(任何一个回答"否"都禁止开始执行): + +1. 我接下来的第一个 tool call 是 `dws minutes ...` 或 `dws doc ...` 吗? +2. 我有没有在用 `session_search` / `memory_search` / `activity:search` / `browser_use` / `read_file` 替代 dws? +3. 用户给了钉钉 URL 时,我有没有从 URL 提取 taskUuid/dentryUuid 后走 dws,而不是 browser_use? +4. 用户的请求模糊(如"总结下我的会议")时,我是先 `dws minutes list mine` 列出来,还是反问要细节? +5. 用户要写报告/总结/日报但没提"听记"时,我有没有判断"任务产出是否依赖会议讨论内容"?如果依赖 → 听记采集不能省略(参见间接意图识别三条铁律) diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py b/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py new file mode 100644 index 00000000..7ca3d651 --- /dev/null +++ b/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +从听记中提取所有待办事项并汇总 + +用法: + python minutes_extract_todos.py # 最近 5 条听记 + python minutes_extract_todos.py --max 10 # 最近 10 条 + python minutes_extract_todos.py --id # 指定听记 + python minutes_extract_todos.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + +_scripts_dir = Path(__file__).resolve().parent +if str(_scripts_dir) not in sys.path: + sys.path.insert(0, str(_scripts_dir)) + +from minutes_list_parse import uuid_title_pairs_from_payload + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description='从听记中提取待办事项' + ) + parser.add_argument('--max', type=int, default=5) + parser.add_argument('--id', default='', help='指定听记 UUID') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + uuids_with_titles = [] + if args.id: + uuids_with_titles = [(args.id, args.id)] + else: + print('🎙️ 获取听记列表...') + data = run_dws([ + 'minutes', 'list', 'mine', + '--max', str(args.max), + '--format', 'json', + ], dry_run=args.dry_run) + if args.dry_run: + run_dws([ + 'minutes', 'get', 'todos', + '--id', '', '--format', 'json', + ], dry_run=True) + return + if not data: + return + uuids_with_titles = uuid_title_pairs_from_payload(data) + + all_todos = [] + for uuid, title in uuids_with_titles: + print(f" 提取待办: {title}") + todos_data = run_dws([ + 'minutes', 'get', 'todos', + '--id', uuid, '--format', 'json', + ]) + if not todos_data: + continue + if isinstance(todos_data, list): + items = todos_data + elif isinstance(todos_data, dict): + inner = todos_data.get('result', todos_data) + if isinstance(inner, dict): + items = inner.get('todos', []) + elif isinstance(inner, list): + items = inner + else: + items = [] + else: + items = [] + for t in items: + if isinstance(t, dict): + t['_source'] = title + all_todos.extend(items) + + print(f"\n📋 听记待办汇总") + print('=' * 50) + + if not all_todos: + print(' ✅ 暂无待办事项') + return + + for t in all_todos: + if not isinstance(t, dict): + print(f" • {t!r}") + continue + content = (t.get('content') or t.get('text') + or t.get('title', '')) + source = t.get('_source', '') + print(f" • {content}") + if source: + print(f" 来自: {source}") + + print(f"\n合计: {len(all_todos)} 条待办") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py b/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py new file mode 100644 index 00000000..712327a9 --- /dev/null +++ b/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py @@ -0,0 +1,65 @@ +"""将 `dws minutes list mine|shared|all` 的 JSON 规范为 (taskUuid, title) 列表。""" + +from __future__ import annotations + +import json +from typing import Any, List, Tuple + + +def _unwrap_rows(payload: Any) -> List[Any]: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + for key in ('result', 'data', 'list'): + value = payload.get(key) + if isinstance(value, list): + return value + if isinstance(value, dict): + for inner_key in ( + 'items', 'list', 'records', 'minutes', + ): + inner = value.get(inner_key) + if isinstance(inner, list): + return inner + return [] + + +def uuid_title_pairs_from_payload(payload: Any) -> List[Tuple[str, str]]: + """列表项可为对象、JSON 字符串、或纯 taskUuid 字符串。""" + out: List[Tuple[str, str]] = [] + for item in _unwrap_rows(payload): + if isinstance(item, dict): + uuid = item.get('taskUuid') or item.get('id') or item.get('task_uuid') + if not uuid: + continue + title = item.get('title') or item.get('name') or '无标题' + # 确保值是基本类型再转换 + if not isinstance(uuid, (str, int, float, bool)): + continue + if not isinstance(title, (str, int, float, bool)): + title = str(title) if isinstance(title, dict) else '无标题' + out.append((str(uuid), str(title))) + elif isinstance(item, str): + text = item.strip() + if not text: + continue + if text.startswith('{'): + try: + parsed = json.loads(text) + except json.JSONDecodeError: + continue + if not isinstance(parsed, dict): + continue + uuid = ( + parsed.get('taskUuid') + or parsed.get('id') + or parsed.get('task_uuid') + ) + if not uuid: + continue + title = parsed.get('title') or parsed.get('name') or '无标题' + out.append((str(uuid), str(title))) + else: + out.append((text, text)) + return out diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py b/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py new file mode 100644 index 00000000..24e8ed44 --- /dev/null +++ b/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +获取最近 N 条听记的 AI 摘要并合并输出 + +用法: + python minutes_recent_summary.py # 最近 5 条 + python minutes_recent_summary.py --max 10 # 最近 10 条 + python minutes_recent_summary.py --output summary.md + python minutes_recent_summary.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from pathlib import Path +from typing import List, Any, Optional + +_scripts_dir = Path(__file__).resolve().parent +if str(_scripts_dir) not in sys.path: + sys.path.insert(0, str(_scripts_dir)) + +from minutes_list_parse import uuid_title_pairs_from_payload + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser( + description='获取最近听记的 AI 摘要' + ) + parser.add_argument( + '--max', type=int, default=5, help='获取条数 (默认 5)' + ) + parser.add_argument( + '--output', default='', help='输出到 Markdown 文件' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print('🎙️ 获取听记列表...') + list_data = run_dws([ + 'minutes', 'list', 'mine', + '--max', str(args.max), + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + run_dws([ + 'minutes', 'get', 'summary', + '--id', '', '--format', 'json', + ], dry_run=True) + return + + if not list_data: + print('未找到听记') + return + + pairs = uuid_title_pairs_from_payload(list_data) + if not pairs: + print('暂无听记') + return + + output_lines = [f"# 最近 {len(pairs)} 条听记摘要\n"] + for i, (uuid, title) in enumerate(pairs, 1): + print(f" [{i}/{len(pairs)}] 获取摘要: {title}") + + summary_data = run_dws([ + 'minutes', 'get', 'summary', + '--id', uuid, '--format', 'json', + ]) + summary_text = '' + if summary_data: + if isinstance(summary_data, str): + summary_text = summary_data + elif isinstance(summary_data, dict): + summary_text = (summary_data.get('summary') + or summary_data.get('content') + or json.dumps(summary_data, + ensure_ascii=False)) + + output_lines.append(f"## {i}. {title}\n") + if summary_text: + output_lines.append(f"{summary_text}\n") + else: + output_lines.append("(暂无摘要)\n") + + full_output = '\n'.join(output_lines) + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + f.write(full_output) + print(f"\n✓ 已输出到 {args.output}") + else: + print('\n' + full_output) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-oa/SKILL.md b/skills/multi/dingtalk-oa/SKILL.md new file mode 100644 index 00000000..903f7097 --- /dev/null +++ b/skills/multi/dingtalk-oa/SKILL.md @@ -0,0 +1,44 @@ +--- +name: dingtalk-oa +description: 钉钉 OA 审批。Use when 用户说 OA/审批/待处理审批/同意审批/拒绝审批/撤销审批/已发起审批/审批记录/批量审批。Distinct from dingtalk-todo(待办任务)、dingtalk-report(日志)。命令前缀:dws oa。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 OA 审批 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[oa.md](references/oa.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "待我处理的审批 / 7 天内待审" | `python scripts/oa_pending_review.py --days 7` | +| "查审批详情" | `dws oa approval detail --instance-id ` | +| "同意 / 拒绝审批" | `dws oa approval approve --instance-id --task-id ` / `reject --instance-id --task-id --remark "<原因>"`(需用户确认) | +| "批量同意 / 批量拒绝" | `python scripts/oa_batch_approve.py --action approve --days 7` | +| "撤销审批" | `dws oa approval revoke --instance-id ` | +| "我已发起的审批" | `dws oa approval list-initiated --process-code --start ""`(processCode 来自 `dws oa approval list-forms`) | + +## 危险操作 + +`approval approve / reject` 不可撤回,必须先向用户展示摘要并获得明确同意,再加 `--yes`。 + +## 跨产品协作 + +- 催别人审批 → 在群里 @对方(`dingtalk-chat`),不要走 #1 消息剧本里的 escalate-ding +- 审批通过后建待办 → 切到 `dingtalk-todo` diff --git a/skills/multi/dingtalk-oa/references/oa.md b/skills/multi/dingtalk-oa/references/oa.md new file mode 100644 index 00000000..c9d9eff9 --- /dev/null +++ b/skills/multi/dingtalk-oa/references/oa.md @@ -0,0 +1,192 @@ +# OA 审批 (oa) 命令参考 + +> **能力范围**:当前 `dws oa approval` 的真实子命令仅有 `approve / detail / list-forms / list-initiated / list-pending / records / reject / revoke / tasks`。`list-executed` / `list-submitted` / `list-cc` / `redirect-task` / `oa-comments` / `oa-cc-noticer` 等命令**目前未实现**,禁止编造——遇到相关诉求时引导用户在钉钉 App 中处理。 + +## 命令总览 + +### 查询待我处理的审批 +``` +Usage: + dws oa approval list-pending [flags] +Example: + dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" + dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --query 关键词 +Flags: + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填) + --page string 分页页码 (可选) + --size string 每页大小 (可选) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填) + --query string 关键字搜索 (可选) +``` + +### 获取审批实例详情 +``` +Usage: + dws oa approval detail [flags] +Example: + dws oa approval detail --instance-id +Flags: + --instance-id string 审批实例 ID (必填) +``` + +### 同意审批 + +> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 + +``` +Usage: + dws oa approval approve [flags] +Example: + dws oa approval approve --instance-id --task-id + dws oa approval approve --instance-id --task-id --remark "同意" +Flags: + --instance-id string 审批实例 ID (必填) + --remark string 审批意见 (可选) + --task-id string 审批任务 ID (必填) +``` + +### 拒绝审批 + +> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 + +``` +Usage: + dws oa approval reject [flags] +Example: + dws oa approval reject --instance-id --task-id --remark "不同意" +Flags: + --instance-id string 审批实例 ID (必填) + --remark string 审批意见 (可选) + --task-id string 审批任务 ID (必填) +``` + +### 撤销已发起的审批 +``` +Usage: + dws oa approval revoke [flags] +Example: + dws oa approval revoke --instance-id --yes + dws oa approval revoke --instance-id --remark "误发起" --yes +Flags: + --instance-id string 审批实例 ID (必填) + --remark string 撤销说明 (可选) +``` + +### 获取审批操作记录 +``` +Usage: + dws oa approval records [flags] +Example: + dws oa approval records --instance-id +Flags: + --instance-id string 审批实例 ID (必填) +``` + +### 查询已发起的审批实例列表 +``` +Usage: + dws oa approval list-initiated [flags] +Example: + dws oa approval list-initiated --process-code --start "2026-03-10T00:00:00+08:00" + dws oa approval list-initiated --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --next-token 0 --max-results 20 +Flags: + --process-code string 表单 processCode (必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (可选, 缺省查到当前) + --max-results int 每页大小,最大 20 (可选, 默认 20) + --next-token int 分页游标,首次传 0 (可选, 默认 0) +``` + +### 获取当前用户可见的审批表单列表 +``` +Usage: + dws oa approval list-forms [flags] +Example: + dws oa approval list-forms --cursor 0 --size 100 +Flags: + --cursor string 分页游标,首次传 0 (默认 "0") + --size string 每页大小,最大 100 (默认 "100") +``` +MCP 工具: `list_user_visible_process`;参数: cursor, pageSize(对应 --cursor/--size)。返回结果含 processCode,可用于 list-initiated 的 --process-code。 + +### 查询待我审批的任务 ID +``` +Usage: + dws oa approval tasks [flags] +Example: + dws oa approval tasks --instance-id +Flags: + --instance-id string 审批实例 ID (必填) +``` +MCP 工具: `list_pending_tasks`。 + + +## 意图判断 + +用户说"待审批/待处理审批" → `approval list-pending` +用户说"审批详情/看审批" → `approval detail` +用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` +用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` +用户说"撤回审批/取消审批" → `approval revoke` +用户说"审批记录/操作历史" → `approval records` +用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取) +用户说"有哪些审批表单/可见表单" → `approval list-forms` +用户说"我有哪些待审的任务" → `approval tasks` + +## 核心工作流 + +```bash +# 1. 查看待我处理的审批 — 提取 processInstanceId +dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --format json + +# 2. 查看审批详情 — 了解审批内容 +dws oa approval detail --instance-id --format json + +# 3. 获取待审批任务 ID — 提取 taskId +dws oa approval tasks --instance-id --format json + +# 4a. 同意审批 +dws oa approval approve --instance-id --task-id --remark "同意" --format json + +# 4b. 拒绝审批 +dws oa approval reject --instance-id --task-id --remark "不符合要求" --format json + +# 5. 撤销自己发起的审批 +dws oa approval revoke --instance-id --remark "误发起" --format json + +# 6. 查看审批操作记录 +dws oa approval records --instance-id --format json + +# 7. 获取可见审批表单(得到 processCode) +dws oa approval list-forms --cursor 0 --size 100 --format json + +# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail) +dws oa approval list-initiated --process-code \ + --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \ + --next-token 0 --max-results 20 --format json + +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `list-pending` | `processInstanceId` | detail / tasks / records / revoke 的 --instance-id | +| `tasks` | `taskId` | approve / reject 的 --task-id | +| `detail` | `processCode` | list-initiated 的 --process-code | +| `list-forms` | `processCode` | list-initiated 的 --process-code | + +## 注意事项 + +- `--start` / `--end` 使用 ISO-8601 格式(如 2026-03-10T00:00:00+08:00) +- `approve` / `reject` 需先通过 `tasks` 获取 `taskId` +- `revoke` 只能撤销自己发起的审批 +- `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 +- `list-initiated` 的 `--process-code` 可从 `list-forms` 或 `detail` 返回中提取 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [oa_pending_review.py](../scripts/oa_pending_review.py) | 查看待审批列表+逐条显示详情 | `python oa_pending_review.py --days 7` | +| [oa_batch_approve.py](../scripts/oa_batch_approve.py) | 批量同意/拒绝审批项 | `python oa_batch_approve.py --action approve --days 7` | diff --git a/skills/multi/dingtalk-oa/scripts/oa_batch_approve.py b/skills/multi/dingtalk-oa/scripts/oa_batch_approve.py new file mode 100644 index 00000000..88b5eef7 --- /dev/null +++ b/skills/multi/dingtalk-oa/scripts/oa_batch_approve.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +批量同意/拒绝待审批项(含安全确认) + +用法: + python oa_batch_approve.py --action approve --days 7 + python oa_batch_approve.py --action reject --remark "不符合要求" + python oa_batch_approve.py --action approve --instance-ids id1,id2 + python oa_batch_approve.py --dry-run --action approve +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f" ✗ 错误:{e}") + return None + + +def to_iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + + +def main(): + parser = argparse.ArgumentParser( + description='批量同意/拒绝审批' + ) + parser.add_argument( + '--action', required=True, + choices=['approve', 'reject'], help='审批动作', + ) + parser.add_argument( + '--remark', default='', help='审批意见' + ) + parser.add_argument('--days', type=int, default=7) + parser.add_argument('--instance-ids', default='') + parser.add_argument( + '--yes', action='store_true', help='跳过确认' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + instance_ids: List[str] = [] + if args.instance_ids: + instance_ids = [x.strip() for x in + args.instance_ids.split(',') if x.strip()] + else: + now = datetime.now() + start = now - timedelta(days=args.days) + data = run_dws([ + 'oa', 'approval', 'list-pending', + '--start', to_iso(start), + '--end', to_iso(now), + '--format', 'json', + ], dry_run=args.dry_run) + if not args.dry_run and data: + if isinstance(data, list): + items = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + items = inner.get('processInstanceList', + inner.get('items', [])) + elif isinstance(inner, list): + items = inner + else: + items = [] + else: + items = [] + instance_ids = [ + item.get('processInstanceId') or item.get('id') + for item in items + if isinstance(item, dict) + and (item.get('processInstanceId') or item.get('id')) + ] + + if not instance_ids and not args.dry_run: + print('✅ 没有待处理的审批') + return + + action_label = '同意' if args.action == 'approve' else '拒绝' + count = len(instance_ids) if instance_ids else '?' + print(f"\n⚠️ 即将 {action_label} {count} 条审批") + if not args.yes and not args.dry_run: + confirm = input('确认执行?(y/N): ').strip().lower() + if confirm != 'y': + print('已取消') + return + + success, fail = 0, 0 + for i, inst_id in enumerate(instance_ids or [''], 1): + tasks_data = run_dws([ + 'oa', 'approval', 'tasks', + '--instance-id', inst_id, + '--format', 'json', + ], dry_run=args.dry_run) + + task_id = None + if not args.dry_run and tasks_data: + if isinstance(tasks_data, list): + task_ids = tasks_data + elif isinstance(tasks_data, dict): + inner = tasks_data.get('result', tasks_data) + if isinstance(inner, dict): + task_ids = inner.get('tasks', inner.get('items', [])) + elif isinstance(inner, list): + task_ids = inner + else: + task_ids = [] + else: + task_ids = [] + if task_ids: + task_id = (task_ids[0] if isinstance(task_ids[0], str) + else task_ids[0].get('taskId', '')) + + cmd_args = [ + 'oa', 'approval', args.action, + '--instance-id', inst_id, + '--task-id', task_id or '', + '--format', 'json', + ] + if args.remark: + cmd_args.extend(['--remark', args.remark]) + + result = run_dws(cmd_args, dry_run=args.dry_run) + if result: + print(f" ✓ [{i}/{count}] {inst_id} → {action_label}") + success += 1 + else: + print(f" ✗ [{i}/{count}] {inst_id}") + fail += 1 + + print(f"\n完成: 成功 {success}, 失败 {fail}") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-oa/scripts/oa_pending_review.py b/skills/multi/dingtalk-oa/scripts/oa_pending_review.py new file mode 100644 index 00000000..440f83c8 --- /dev/null +++ b/skills/multi/dingtalk-oa/scripts/oa_pending_review.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +查看待我审批列表 + 逐条显示详情(自动时间戳计算) + +用法: + python oa_pending_review.py # 最近 7 天 + python oa_pending_review.py --days 30 # 最近 30 天 + python oa_pending_review.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def to_iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + + +def main(): + parser = argparse.ArgumentParser( + description='查看待我审批列表' + ) + parser.add_argument( + '--days', type=int, default=7, help='查询天数 (默认 7)' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + now = datetime.now() + start = now - timedelta(days=args.days) + + print(f"📋 查询待审批 (最近 {args.days} 天)...") + data = run_dws([ + 'oa', 'approval', 'list-pending', + '--start', to_iso(start), + '--end', to_iso(now), + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + run_dws([ + 'oa', 'approval', 'detail', + '--instance-id', '', + '--format', 'json', + ], dry_run=True) + return + + if not data: + print('未查到待审批') + return + + if isinstance(data, list): + instances = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + instances = inner.get('processInstanceList', + inner.get('items', [])) + elif isinstance(inner, list): + instances = inner + else: + instances = [] + else: + instances = [] + if not instances: + print('✅ 暂无待审批事项') + return + + print(f"\n🔔 待审批列表 ({len(instances)} 条)") + print('=' * 50) + + for i, inst in enumerate(instances, 1): + if not isinstance(inst, dict): + print(f"\n [{i}] {inst}") + continue + inst_id = (inst.get('processInstanceId') + or inst.get('id', '')) + title = inst.get('title') or inst.get('name', '无标题') + status = inst.get('status') or inst.get('result', '') + create_time = inst.get('createTime', '') + if isinstance(create_time, (int, float)): + create_time = datetime.fromtimestamp( + create_time / 1000 + ).strftime('%Y-%m-%d %H:%M') + + print(f"\n [{i}] {title}") + print(f" 状态: {status} 创建: {create_time}") + print(f" ID: {inst_id}") + + detail = run_dws([ + 'oa', 'approval', 'detail', + '--instance-id', inst_id, + '--format', 'json', + ]) + if detail and isinstance(detail, dict): + forms = detail.get('formComponentValues', []) + if forms: + print(f" --- 表单内容 ---") + for f in forms[:5]: + name = f.get('name', '') + value = f.get('value', '') + if value: + print(f" {name}: {value[:60]}") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-report/SKILL.md b/skills/multi/dingtalk-report/SKILL.md new file mode 100644 index 00000000..149e3df5 --- /dev/null +++ b/skills/multi/dingtalk-report/SKILL.md @@ -0,0 +1,47 @@ +--- +name: dingtalk-report +description: 钉钉日志(日报 / 周报 / 月报)。Use when 用户说 写日报/写周报/写月报/提交日志/查日志/收件箱日志/已发送日志/已读统计/按主题汇总报告。Distinct from dingtalk-doc(普通文档)、dingtalk-todo(待办)、dingtalk-minutes(听记)。命令前缀:dws report(别名 dws log)。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉日志 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[report.md](references/report.md);剧本:[05-reporting.md](references/05-reporting.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "今天收到的日志" | `python scripts/report_received_today.py` | +| "看日志模版" | `dws report template list` → `dws report template detail --name "<模版名>"` | +| "提交日报 / 周报(按模版)" | `dws report create --template-id --contents '[...]' | +| "我已发送的日志" | `dws report sent --start --end ` | +| "日志已读统计" | `dws report stats --report-id ` | +| "生成日报 / 周报 / 月报 / 主题报告" | 见 [05-reporting.md](references/05-reporting.md) recipe | + +## 日志查询硬约束 + +- 查“收到的日志”必须用 `dws report list --start "" --end "" --cursor 0 --size 20 --format json`,并把“今天 / 最近 30 天”等时间词先展开成完整 ISO 起止时间。 +- 列表返回后,后续 `detail` / `stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。 +- 用户要正文时用 `dws report detail --report-id `;用户要已读/统计时用 `dws report stats --report-id `。 + +## 跨产品协作 + +- 日报内容来源(待办 / 听记 / OA / 邮件 / 群消息)→ 多源采集,按 dws-shared 的 conventions.md 并行执行 +- 把汇总写文档 → 切到 `dingtalk-doc`(`dws doc create` + `dws doc update --mode append`) +- 注意:`submit-report` 走 report 模版提交,**不要**走 doc 写文档 diff --git a/skills/multi/dingtalk-report/references/05-reporting.md b/skills/multi/dingtalk-report/references/05-reporting.md new file mode 100644 index 00000000..4f8c3e96 --- /dev/null +++ b/skills/multi/dingtalk-report/references/05-reporting.md @@ -0,0 +1,29 @@ +# 工作汇报 + +> lite recipe 见 `dws-shared/references/best_practices/_common/lite-recipes.md`。 + +## 路径分歧(先判定后选 recipe) + +`dws report` 与 `dws doc` 是两个不同的产品,覆盖不同的"周报 / 日报"场景。在选 recipe 前先做一次判定: + +| query 中是否含强信号 | 选哪个 recipe | 默认值 | +|---------------------|---------------|--------| +| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report create) | — | +| 含「在线文档 / 写一篇文档 / 整理成文档 / 文档保存」 | `generate-*-report`(走 dws doc create) | — | +| **无强信号**(如"写日报"、"写周报"、"整理本周工作")| `generate-*-report`(走 dws doc create) | 默认 | + +注: + +- "钉钉日志"在用户口语中多指 OA 周报应用,但偶有泛指日志/记录,必要时反问澄清。 +- 仅当用户**明确**说"钉钉日志(OA 应用)"或类似强信号时才切到 `submit-report`;否则不要主动推荐 OA 日志路径——多数用户的"周报"实际期望是文档(可分享、可编辑、长文本)。 + +## Recipe 速查 + +| Recipe | 行动指南(固定路线) | +|--------|-------------------| +| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清
1. 用户说「我发过 / 我创建」→ `report sent --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report list --start "" --end "" --cursor 0 --size 20 --format json`(注意:`dws report inbox` 当前 CLI 未实现,禁止编造)
2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证
3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表
4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造
5. 用户要看正文时再执行 `report detail --report-id --format json`;用户要统计 / 已读情况时执行 `report stats --report-id --format json`
**不要把 list/sent 当正文接口**;查询正文必须补 `detail` | +| generate-daily-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=今日)
2. 交叉汇总并把日报内容写入临时文件 `.md`(UTF-8,真实换行)
3. **创建文档**:`doc create --name "<日报名>" --content-file .md`(> 200KB 按 `dingtalk-doc/references/04-document.md` 的 write-doc 兜底走 create 空 → 循环 update) | +| generate-weekly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=本周)
2. 交叉对比并把周报内容写入临时文件 `.md`
3. **创建文档**:`doc create --name "<周报名>" --content-file .md`(兜底同上) | +| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)
1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当日)
2. `report template list --format json` → 取 `report_template_id`
3. `report template detail --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`
4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`
5. `report create --template-id --contents '[...]' (或 echo "$json" | ... --contents -) --format json` → CLI 会在创建成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接
6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 create 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report detail --report-id --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`
**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 create;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link | +| generate-monthly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当月)
2. `report sent --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志
3. 按周分段归纳并把月报内容写入临时文件 `.md`
4. **创建文档**:`doc create --name "<月报名>" --content-file .md`(兜底同上) | +| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)
2. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行
3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `.md`
4. **创建文档**:`doc create --name "<报告名>" --content-file .md`(兜底同上) | diff --git a/skills/multi/dingtalk-report/references/report.md b/skills/multi/dingtalk-report/references/report.md new file mode 100644 index 00000000..a09f2e81 --- /dev/null +++ b/skills/multi/dingtalk-report/references/report.md @@ -0,0 +1,395 @@ +# 日志 (report) 命令参考 + +> ⚠️ 此处 `report` 特指钉钉 OA 周报应用(按模板填报、收到/已发列表、统计),不是通用日志/记录文件。 + +## 载体辨义(首屏必读) + +`dws report` 管理「钉钉日志」OA 应用(按模版填报、收到列表、已发列表、统计),**不是**钉钉在线文档。Agent 在拿到用户 query 时按下表选择命令族: + +| 用户原话信号 | 命令族 | 不走 | +|-------------|--------|------| +| 钉钉日志 / OA 周报 / 周报模板 / 日报模板 / 我的钉钉日志 / 写日志 / 提交周报 / 填模版 | `dws report` | 不走 dws doc | +| 在线文档 / 写一篇文档 / 整理成文档 / 周报文档 / 月报文档 / 用文档保存 | `dws doc` | 不走 dws report | +| (无强信号)写日报 / 写周报 / 写月报 | **默认** `dws doc` | 仅当用户后续明确指定钉钉日志时才切到 dws report | + +- 默认走 `dws doc`:长文本编辑、富文本、可分享链接的场景。 +- 切到 `dws report`:query 中出现「钉钉日志 / OA 周报模板 / 钉钉日报应用 / 我的钉钉日志」等强信号时(不依赖词典扩张,依赖语义判断 + 必要时反问澄清)。 +- 信号歧义时应先反问「您指的是钉钉日志(OA 周报应用)还是钉钉在线文档?」而不是默认选一个。 + +## 查日志快速调度(Agent 首选) + +当用户说「查日志 / 看日志 / 找日报 / 查看周报 / 我发过的日志 / 收到的日志」且语义指向钉钉日志 OA 应用时,Agent 不要先解释概念,直接按下面指令调度: + +硬语义: + +- `dws report list` = 查询当前用户收到的日志,也就是别人发给我的日志。 +- `dws report sent` = 查询当前用户创建或发出的日志。 +- 用户要“最近一周收到的日志”时必须用 `dws report list --start "" --end "" --cursor 0 --size 20 --format json`,不要换成 `sent`,不要回答“API 不支持收到的日志”。 +- `dws report inbox` 当前 CLI 未实现——禁止编造,所有「收到的日志」诉求必须走 `dws report list --start ... --end ...`。 + +| 用户意图 | 第一条有效指令 | 后续动作 | +|----------|----------------|----------| +| 查我发过的日志 / 我创建的日志 | `dws report sent --cursor 0 --size 20 --format json` | 从返回里取 `reportId`,再执行 `dws report detail --report-id --format json` | +| 查我收到的日志 / 别人发给我的日志 | `dws report list --start "" --end "" --cursor 0 --size 20 --format json` | 必须先按用户时间词补齐完整 ISO 起止时间;取 `reportId` 后查 detail | +| 查看某条日志正文 / 日志详情 | `dws report detail --report-id --format json` | 如果用户没给 `reportId`,先用 `sent` 或 `list` 找候选 | +| 查某条日志统计 / 已读统计 | `dws report stats --report-id --format json` | 如果用户没给 `reportId`,先用 `sent` 或 `list` 找候选 | +| 查日志模版 / 有哪些周报模板 | `dws report template list --format json` | 需要字段定义时继续 `dws report template detail --name "<模版名>" --format json` | +| 提交 / 填写 / 创建钉钉日志 | `dws report template list --format json` | 再 detail 取字段定义,最后 create;禁止直接编 contents | + +效率约束: + +- 不要先调用 `dws report --help` / `dws report list --help`。本页已经给出可执行命令。 +- 查询收到的日志必须用 `dws report list --start ... --end ...`;当前 CLI 没有 `dws report inbox` 子命令。 +- 不要为了格式化结果创建脚本。直接从 JSON 里抽取关键字段,返回用户可读列表。 +- 对“最近一周”这类常用查询,直接把当前日期换算成 7 天窗口后执行一次 `report list`;如返回分页标记再继续翻页。 + +时间窗口默认规则: + +- `sent` 不传 `--start` / `--end` 时 CLI 默认最近 20 天,适合“我发过的日志 / 最近日志”。 +- `list` 必须传 `--start` / `--end`;用户说“最近一周/今天/本周/昨天”时,Agent 必须先转成 `YYYY-MM-DDT00:00:00+08:00` 到 `YYYY-MM-DDT23:59:59+08:00`。 +- 查更早日志时每次窗口不要超过 20 天,按窗口滚动查询。 + +时间参数硬约束: + +- 只允许使用 `--start` 和 `--end` 两个 flag;禁止写 `--start-date`、`--end-date`、`--date`、`startDate`、`endDate`。 +- 时间值推荐使用完整 ISO-8601 + 时区格式:`YYYY-MM-DDTHH:mm:ss+08:00`。 +- Agent 不要只传裸日期;即使 CLI 能兼容 `YYYY-MM-DD`,生成命令时也必须展开成当天起止时间,避免工具层把日期误解析成普通字符串。 +- “最近一周”固定展开为:起始日 `T00:00:00+08:00`,结束日 `T23:59:59+08:00`。 + +错误示例(不要生成): + +```bash +dws report list --start-date 2026-05-04 --end-date 2026-05-11 --format json +dws report list --date 2026-05-11 --format json +``` + +正确示例: + +```bash +dws report list --start "2026-05-04T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00" --cursor 0 --size 20 --format json +``` + +快速决策: + +```bash +# 我发过 / 我创建的日志 +dws report sent --cursor 0 --size 20 --format json +dws report detail --report-id --format json + +# 我收到的日志 +dws report list --start "" --end "" --cursor 0 --size 20 --format json +dws report detail --report-id --format json + +# 已知 reportId +dws report detail --report-id --format json +``` + +## 日志列表展示规范 + +面向用户展示时,默认不要把 `reportId` / `report_id` / `report_Id` 作为主列。日志 ID 只给 Agent 后续调用 `detail` / `stats` 使用,用户一般不需要看。 + +优先展示这些字段: + +| 展示列 | 取值优先级 | +|--------|------------| +| 日期 | `createTime` / `modifiedTime` / `gmtCreate`,转成本地可读时间 | +| 标题 | `report_name` / `reportName` / `title` / `summary` / `report_template_name`,没有标题时用「<发送人>的日志」 | +| 发送人 | `creatorName` / `senderName` / `userName` | +| 已读状态 | `readStatus` / `isRead` / `hasRead` / `read`;字段缺失时不展示,不要编造 | +| 模板 | `report_template_name` / `templateName`,用户关心分类时展示 | +| 查看详情 | 从 `_internalDetailCommands[].command` 取内部详情命令;面向用户展示为“查看详情”,不要展示命令本身 | +| 钉钉链接 | 详情接口返回 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `result.url` 后,展示成 markdown 链接 | + +CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本直接输出。`report list` 会对当前页 `result[]` 中所有带 `reportId` 的日志逐条调用 `dws report detail --report-id --format json`,但只从 detail 中取 `日期`、`标题`、`发送人`、`状态`、`钉钉链接` 等元信息;**列表接口禁止返回日志正文、完整内容或日志内容摘要**。返回 JSON 会携带 `agentDisplayRequired=true`、`agentDisplayRequiredColumns`、`agentDisplayLinkColumn=钉钉链接`、`agentDisplayMarkdownRequired=true`、`agentDisplayMarkdown`、`agentDisplayMarkdownHeader` 等字段;这些是面向 Agent 的强制展示合同,不是可选建议。Agent 必须在 final reply 直接原样输出 `agentDisplayMarkdown`,让客户端按 Markdown 渲染出带可点击 `钉钉链接` 列的表格;不要自行重组列,不要改表头,不要退化成四列表。`list` 表头必须逐字使用 `| 日期 | 标题 | 发送人 | 状态 | 钉钉链接 |`。凡用户说“列出 / 找到 / 查询 / 搜索 / 看看日志”,默认都要在 final reply 渲染 Markdown 表;只有用户明确表示“不关心列表内容 / 只要原始 JSON / 只要数量 / 只要 ID”时,才可以不渲染表。推荐用户可读输出: + +| 日期 | 标题 | 发送人 | 状态 | 钉钉链接 | +|------|------|--------|------|----------| +| 2026-05-09 23:08 | 张成强的周报 | 张成强 | 未读 | [在钉钉中查看日志](...) | + +操作列规则: + +- `list` 列表阶段:每条 `result[]` 只带 `日期` / `标题` / `发送人` / `状态` / `钉钉链接` 五个展示字段;使用 `result[].钉钉链接` 作为可点击操作列。禁止额外返回或展示 `日志内容` / `日志内容摘要`。如果用户点名某一条或说“打开第 N 条/看正文”,Agent 用 `_internalDetailCommands[N].command` 调 `dws report detail --report-id ... --format json`。 +- 详情阶段:`detail` 返回里如果有 `dingtalkOpenMarkdownLink`,优先把操作列替换为该 markdown 链接;否则用 `dingtalkOpenUrl` 或 `result.url` 包成 `[在钉钉中查看日志](url)`。 +- `钉钉链接` 是强制列:禁止省略、改名、合并到标题里,也禁止改成不含链接列的摘要表。 +- 不要在用户表格里展示 `_internalDetailCommands`、raw `reportId`、raw `dingtalk://...`。链接必须是 markdown 可点击文本。 +- `dws report list` 默认会对当前页所有可查看日志自动补 `detail`,但只返回日期、标题、发送人、状态与可点击钉钉链接;需要正文时必须显式调用 `dws report detail --report-id ... --format json`。 + +只有在用户明确要求“给我日志 ID / 方便我后续查询”时,才额外展示 `reportId`。否则 final reply 应保留可读信息,并说明“需要看正文或打开钉钉,我可以继续打开某一条”。 + +## 提交链路硬约束(必读) + +当用户意图涉及"填模板 / 提交日志 / 提交日报 / 提交周报"等需要 create 的场景时,**必须**按以下步骤执行,**禁止跳步**: + +1. `dws report template list --format json` — 取 `report_template_id` 与可见模版名 +2. `dws report template detail --name "<模版名>" --format json` — 取 `result.report_template_fields[]`,每项含 `field_name` / `field_sort` / `field_type` +3. `dws report create --template-id --contents '[...]' --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;CLI 创建成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` +4. 仅当第 3 步返回中缺少 `dingtalkOpenUrl` 时,执行 `dws report detail --report-id --format json` 补取 `result.url`(`dingtalk://...` 协议深链接)。final reply 中优先使用 `dingtalkOpenMarkdownLink`,否则用 `[在钉钉中查看日志](dingtalkOpenUrl)`。**禁止把 raw `dingtalk://...` URL 原样写进回复**,必须包成 markdown link 让用户可点击跳转钉钉客户端 + +跳步风险(已实证): + +- 跳过第 1 步直接编 templateId → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个 ID 错**; +- 跳过第 2 步用 LLM 经验编 `key` 名 → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个字段错**;服务端 PARAM_ERROR 信号弱,事后无法定位,**只能靠前置 schema 同步避免**; +- 未取到 `dingtalkOpenUrl` 且不补查 detail → 用户拿不到跳转链接,无法在钉钉客户端打开刚提交的日志查看 / 修改; +- 用 `--contents` 直传长 JSON → shell 引号转义破坏 JSON → `INPUT_INVALID_JSON`。**长内容务必走 `--contents -` (stdin),e.g. `echo "$json" | dws report create ... --contents -`**。 +- contents JSON 大小限制为 10MB,**不支持分批次提交**。超过限制需精简内容或拆分为多个独立日志提交。 + +推荐:Agent 在多轮场景中应在内存里持久化第 1/2 步的结果,避免每轮重新跑。 + +## 命令总览 + +### 获取日志模版列表 +``` +Usage: + dws report template list [flags] +Example: + dws report template list +``` + +### 获取日志模版详情 +``` +Usage: + dws report template detail [flags] +Example: + dws report template detail --name +Flags: + --name string 模版名称 (必填) +``` + +### 创建日志 +``` +Usage: + dws report create [flags] +Example: + # 推荐:长内容走文件,避免 shell 引号问题 + dws report create --template-id --contents '[...]' --format json + + # stdin 输入 + cat report.json | dws report create --template-id --contents - --format json + + # 内联(短内容) + dws report create --template-id \ + --contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \ + --format json +Flags: + --template-id string 日志模版 ID (必填),从 template list 返回中取 + --contents string 日志内容 JSON 数组 (必填);含中文换行/markdown 时建议用 stdin: --contents - 配合 echo "$json" | dws report create ... + --dd-from string 创建来源标识 (默认 dws) + --to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true) + --to-user-ids string 接收人 userId,逗号分隔 (可选) +``` + +**`contents` 数组元素**(与 MCP `create_report` 一致): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `key` | string | 控件名,**与 `template detail` 返回的 `field_name` 完全一致**(不要自己改写) | +| `sort` | string | 控件排序,对齐 `template detail` 的 `field_sort`(建议传字符串 `"0"`/`"1"` 等) | +| `type` | string | 控件类型,对齐 `template detail` 的 `field_type`:`1` 文本 / `2` 数字 / `3` 单选 / `5` 日期 / `7` 多选 | +| `content` | string | 填写值;`type=1` 文本类支持 Markdown | +| `contentType` | string | `type=1` 时通常用 `markdown`,其余用 `origin` | + +**字段名对齐**:`template detail` 返回 `result.report_template_fields[].{field_name, field_sort, field_type}`(snake_case),拼 `--contents` 时 **逐一映射**:`field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`。**不要自己编 key 名**,必须从 detail 返回值取,否则服务端会返回不可定位的 `PARAM_ERROR`。 + +**长内容传参**:含中文换行 / Markdown / 引号的场景用 stdin:`echo "$json" | dws report create --template-id --contents - --format json`,避免 shell 引号转义。 + +### 获取日志详情 +``` +Usage: + dws report detail [flags] +Example: + # 先通过 report list / report sent / report create 取得 reportId,再查正文与跳转链接 + dws report detail --report-id +Flags: + --report-id string 日志 ID (必填) +``` + +**关键返回字段**: + +| 字段 | 含义 | Agent 用法 | +|------|------|-----------| +| `result.url` | `dingtalk://dingtalkclient/action/openapp?...` 协议深链接 | **必须包成 markdown link**:`[查看日报](result.url)`。点击后会在钉钉客户端打开该日志详情页。**禁止**把 raw `dingtalk://...` URL 直接粘到回复里——多数终端 / 聊天界面无法对裸协议自动可点击 | +| `result.report_content[]` | 各控件正文(`key` / `value` / `richTextValue` 等)| 需要展示正文时读这里;`richTextValue` 是富文本编码,普通展示用 `value` | +| `result.report_name` / `result.creatorName` / `result.createTime` | 日志元信息 | final reply 给用户摘要时一并展示 | + +### 收到的日志列表 +``` +Usage: + dws report list [flags] +Example: + dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20 + # 禁止:--start-date / --end-date / --date + # 从列表结果提取 reportId 后,再查正文 + dws report detail --report-id +Flags: + --cursor int 分页游标,首次传 0 (默认 0) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填) + --size int 每页条数,最大 20 (默认 20),别名: --limit + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填) +``` + +`list` 默认返回 JSON-first 结构:不会把 Markdown 表作为裸文本直接输出,但 JSON 里会返回 `agentDisplayMarkdown`。Agent 必须在 final reply 直接原样输出 `agentDisplayMarkdown`,让客户端按 Markdown 渲染出表格;不要基于个人偏好重新摘要。表头固定且必须逐字为 `| 日期 | 标题 | 发送人 | 状态 | 钉钉链接 |`,并按返回顺序展示(CLI 已按创建时间从近到远排序)。返回 JSON 中的 `agentDisplayRequired=true`、`agentDisplayRequiredColumns`、`agentDisplayLinkColumn=钉钉链接`、`agentDisplayMarkdownRequired=true`、`agentDisplayMarkdownHeader` 都是强制展示合同。凡用户说“列出 / 找到 / 查询 / 搜索 / 看看日志”,final reply 默认必须渲染这张 Markdown 表;只有用户明确表示“不关心列表内容 / 只要原始 JSON / 只要数量 / 只要 ID”时,才可以不渲染表。`result[]` 每项都包含且只展示这五个字段:`日期`、`标题`、`发送人`、`状态`、`钉钉链接`。`钉钉链接` 是强制可点击 markdown 链接列,禁止省略、改名、合并到标题里;`list` 禁止返回或展示 `日志内容` / `日志内容摘要`。`reportId` 不在主结果里,只通过 `_internalDetailCommands` 保留给 Agent 后续调用 `detail` / `stats`;final reply 禁止展示 `_internalDetailCommands`、毫秒时间戳、raw ID 或日志正文。 + +### 获取日志统计数据 +``` +Usage: + dws report stats [flags] +Example: + dws report stats --report-id +Flags: + --report-id string 日志 ID (必填) +``` + +### 查询当前人创建的日志列表 +``` +Usage: + dws report sent [flags] +Example: + dws report sent --cursor 0 --size 20 + dws report sent --cursor 0 --size 20 --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" + dws report sent --cursor 0 --size 20 --template-name "日报" + # 从列表结果提取 reportId 后,再查正文 + dws report detail --report-id +Flags: + --cursor int 分页游标,首次传 0 (默认 0) + --size int 每页条数,最大 20 (默认 20),别名: --limit + --start string 创建开始时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天) + --end string 创建结束时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天) + --modified-start string 修改开始时间 ISO-8601 (可选) + --modified-end string 修改结束时间 ISO-8601 (可选) + --template-name string 日志模版名称 (可选,不传查全部) +``` + +`sent` 同样主要返回已发送日志的 ID 和摘要;要查看正文,继续用 `detail`。 + +**默认时间窗口(v0.3+)**:`--start` / `--end` 未传时,CLI 自动回退到最近 20 天(服务端 `get_send_report_list` 单次查询跨度**上限 20 天**,超过会被服务端拒绝),并在 stderr 输出一行 informational: + +``` +# info: --start / --end not provided, defaulting to last 20 days ( ~ ); server caps single-query span at 20 days, pass explicit --start to shift the window +``` + +查更早数据需**多次调用**并显式滚动 `--start` / `--end`(每次跨度 ≤ 20 天),不能一次性传超过 20 天范围;不要假定 `sent` 不传时间窗口就是全量。 + +## 两步读取正文 + +读取已有日志内容时,统一按下面两步走,不要把列表接口当正文接口: + +1. `dws report list ...` 或 `dws report sent ...`,先拿到目标日志的 `reportId` +2. `dws report detail --report-id `,再读取正文和字段明细 + +适用场景: + +- “看我今天收到的某条周报正文” +- “把我发过的日报正文拉出来继续汇总” +- “先按时间范围筛日志,再读取具体内容” + +## 意图判断 + +用户说"查日志/看日报/看正文" → `list` 或 `sent` 获取列表,再 `detail` +用户说"写日报/提交周报/发日志/填日志" → 先 `template list` / `template detail` 取 `templateId` 与各控件 `key`/`sort`/类型,拼 `--contents` JSON,再 `create` +用户说"日志统计/已读统计" → `stats` +用户说"有什么日志模版" → `template list` 或 `template detail` +用户说"我发过的日志/我创建的日志" → `sent` + +关键区分: report(钉钉日志模版汇报,含创建) vs doc(文档编辑) vs todo(待办任务) + +## 核心工作流 + +```bash +# 1. 获取当前用户可用的日志模版 +dws report template list --format json + +# 2. 按名称查看模版详情(含字段定义) +dws report template detail --name "日报" --format json + +# 2b. 创建日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents '[...]' shell 引号 +dws report create --template-id --contents '[...]' --format json +# create 成功会自动反查详情并追加 dingtalkOpenMarkdownLink / dingtalkOpenUrl; +# final reply 直接使用 dingtalkOpenMarkdownLink: [在钉钉中查看日志](dingtalk://...) + +# 2c. 仅当 create 返回中缺少 dingtalkOpenUrl 时,手动补取 dingtalk:// 跳转链接 +dws report detail --report-id --format json +# → 取 result.url,final reply: [在钉钉中查看日志](result.url) + +# 3. 查看收到的日志列表 — 提取 reportId +dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \ + --cursor 0 --size 20 --format json + +# 4. 查看日志详情(正文/字段明细) +dws report detail --report-id --format json + +# 5. 查看日志统计(已读/未读) +dws report stats --report-id --format json + +# 6. 查看当前人创建的日志列表 +dws report sent --cursor 0 --size 20 --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `template list` | template 名称(result.items[].report_template_name) | `template detail` 的 --name | +| `template list` | `report_template_id` | `create` 的 --template-id | +| `template detail` | `result.report_template_fields[].field_name` / `field_sort` / `field_type` | 拼 `create` 的 --contents JSON(按下表映射)| +| `create` | `reportId`、`dingtalkOpenMarkdownLink`、`dingtalkOpenUrl`(CLI 自动反查详情后追加)| final reply 优先直接使用 `dingtalkOpenMarkdownLink`;需要结构化展示时用 `dingtalkOpenLink.title` + `dingtalkOpenLink.url` | +| `list` / `sent` | `reportId` | detail / stats 的 --report-id | +| `detail` | `result.url`(`dingtalk://...`)| create 未返回 `dingtalkOpenUrl` 或查看已有日志时,final reply 中以 markdown link 形式给用户:`[在钉钉中查看日志](result.url)` | + +**`template detail` → `create --contents` 字段映射**(必须严格对齐,不要自己改写字段名): + +| `template detail` 返回 | `create --contents` 字段 | +|------------------------|--------------------------| +| `field_name`(string)| `key` | +| `field_sort`(number)| `sort`(string,例如 `"0"`) | +| `field_type`(number)| `type`(string,例如 `"1"`) | +| —(用户填写)| `content` | +| —(推断)| `contentType`(`type=1` 用 `markdown`,其余用 `origin`) | + +## 注意事项 + +- `--start` / `--end` 使用 ISO-8601 格式(如 `2026-03-10T00:00:00+08:00`) +- `template list` 不需要参数,直接返回当前用户可用的所有日志模版 +- `create` 前必须先查模版(参见「提交链路硬约束」),勿猜测 `templateId` 或 `contents` 中的 `key`/`sort`;多控件时数组须覆盖模版必填项 +- `list` / `sent` 默认用于筛选目标日志,不保证返回完整正文;读取正文请继续调用 `detail` +- `sent` 不传时间窗口默认最近 20 天(服务端单次查询跨度上限 20 天,超过会被拒绝),CLI 会在 stderr 输出 informational 提示;查更早数据需多次滚动调用,每次跨度 ≤ 20 天 +- `create` 的 contents JSON 大小限制为 **10MB**,**不支持分批次提交**。超出限制时需精简内容或将内容拆分为多个独立日志分别提交 + +## 常见错误诊断(CLI Code → 真实含义 → 建议动作) + +错误码均落到 errors.go 既有 `INPUT_*` / `MCP_*` / `RESOURCE_*` 体系,对应进程退出码见全局错误码文档。 + +| Code | ExitCode | 真实含义 | 建议动作 | +|------|---------|---------|---------| +| `INPUT_INVALID_JSON` | 3 | `--contents` 内容非合法 JSON | 检查 JSON 数组结构,每项必须是 object,含 `key`/`sort`/`content`/`contentType`/`type` 五个字段;含中文/换行时改用 stdin (`--contents -`) | +| `INPUT_MISSING_PARAM` | 3 | `--template-id` / `--contents` 必填缺失 | 显式传值;从 `template list` 取合法 templateId | +| `INPUT_TOO_LARGE` | 3 | contents JSON 超过 10MB 限制 | **不支持分批次提交**。需精简内容或拆分为多个独立日志分别提交 | +| `MCP_TOOL_ERROR` | 1 | 服务端业务错(含 `server_error_code: PARAM_ERROR`,覆盖 templateId 错 / 字段名错 / 字段值错 / contents 空等多种形态)| 查看 `server_error_code` / `technical_detail`;服务端不区分具体子错因,按提交链路重新走 `template list → template detail → create`;连续 ≥ 2 次仍失败必须停止重试,降级 final_reply | +| `RESOURCE_NOT_FOUND` | 1 | reportId / templateId 在服务端找不到 | 用 `list` 或 `template list` 重新获取 | + +## 何时停止重试 + +`dws report create` 在以下情况下**必须停止重试**,转为降级 final_reply: + +1. 同一 templateId 连续 ≥ 3 次返回 PARAM_ERROR / INVALID_CONTENTS 类错误,且每次重试只是改 contents 字段名 / 格式而未重读 schema; +2. 出现服务端不可读的 PARAM_ERROR(technical_detail 仅含 `root.success当前值`)—— 即使只 1 次也应停止; +3. 长 contents 通过 stdin 传入仍持续报 `INPUT_INVALID_JSON`(说明 JSON 本身格式错,重试无意义) + +降级 final_reply 模板: + +> 当前 `dws report create` 在该模版下持续返回不可恢复错误(已尝试 N 次,错误码 ``)。建议您: +> +> 1. 在钉钉客户端打开「日志」应用,选择「<模版名>」模版; +> 2. 复制下面的内容粘贴到对应字段; +> 3. 提交。 +> +> 我已记录本次失败 trace,会同步给 dws 团队修复。 + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| + +## 相关产品 + +- `dingtalk-doc` (`references/doc.md`) — 长文本文档创作(钉钉在线文档),不是日志模版 +- `dingtalk-todo` (`references/todo.md`) — 个人任务管理,不是日志汇报 diff --git a/skills/multi/dingtalk-report/scripts/report_received_today.py b/skills/multi/dingtalk-report/scripts/report_received_today.py new file mode 100644 index 00000000..a08e8777 --- /dev/null +++ b/skills/multi/dingtalk-report/scripts/report_received_today.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +查看今天收到的日志列表及详情 + +用法: + python report_received_today.py + python report_received_today.py --days 3 # 最近 3 天 + python report_received_today.py --dry-run +""" + +import sys +import json +import subprocess +import argparse +from datetime import datetime, timedelta +from typing import List, Any, Optional + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def to_iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') + + +def main(): + parser = argparse.ArgumentParser( + description='查看收到的日志' + ) + parser.add_argument( + '--days', type=int, default=1, help='查询天数 (默认 1)' + ) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + now = datetime.now() + start = now - timedelta(days=args.days) + start = start.replace(hour=0, minute=0, second=0) + + label = '今天' if args.days == 1 else f'最近 {args.days} 天' + print(f'📓 查看{label}收到的日志...\n') + + data = run_dws([ + 'report', 'list', + '--start', to_iso(start), + '--end', to_iso(now), + '--cursor', '0', + '--size', '20', + '--format', 'json', + ], dry_run=args.dry_run) + + if args.dry_run: + return + if not data: + print('未查到日志') + return + + if isinstance(data, list): + reports = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + reports = inner.get('report_list', + inner.get('reports', [])) + elif isinstance(inner, list): + reports = inner + else: + reports = [] + else: + reports = [] + if not reports: + print(' ✅ 暂无收到的日志') + return + + print(f"📓 {label}日志 ({len(reports)} 条)") + print('=' * 50) + + for r in reports: + if not isinstance(r, dict): + print(f"\n 📝 {r}") + continue + rid = r.get('reportId') or r.get('id', '') + creator = r.get('creatorName') or r.get('creator', '未知') + template = r.get('templateName') or r.get('template', '') + create_time = r.get('createTime', '') + if isinstance(create_time, (int, float)): + create_time = datetime.fromtimestamp( + create_time / 1000 + ).strftime('%Y-%m-%d %H:%M') + + print(f"\n 📝 {template or '日志'} - {creator}") + print(f" 时间: {create_time}") + print(f" ID: {rid}") + + if rid: + detail = run_dws([ + 'report', 'detail', + '--report-id', rid, '--format', 'json', + ]) + if detail and isinstance(detail, dict): + contents = detail.get('contents', []) + for c in contents[:3]: + key = c.get('key') or c.get('title', '') + val = c.get('value') or c.get('content', '') + if key and val: + print(f" {key}: {str(val)[:60]}") + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-sheet/SKILL.md b/skills/multi/dingtalk-sheet/SKILL.md new file mode 100644 index 00000000..fa6c0d0a --- /dev/null +++ b/skills/multi/dingtalk-sheet/SKILL.md @@ -0,0 +1,59 @@ +--- +name: dingtalk-sheet +description: 钉钉电子表格。Use when 用户说 电子表格/工作表/单元格读写/单元格追加/查找/公式/超链接/插入图片/浮动图片/sheet。Distinct from dingtalk-aitable(AI表格/多维表/字段类型)、dingtalk-doc(普通文档)。命令前缀:dws sheet。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉电子表格 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[sheet.md](references/sheet.md);URL 识别与类型探测:[url-patterns.md](references/url-patterns.md)。 + +## URL 预检(含 alidocs URL 时必读) + +输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式:`/i/p/...`(分享短链)、`/i/nodes/...`(节点链接,类型需探测)、`/spreadsheetv2/...`(电子表格直链,直接路由到 `sheet`)、`/document/edit|preview?dentryKey=...`(文档链接,路由到 `dingtalk-doc`)等。**必须先读 [url-patterns.md](references/url-patterns.md) 中的「alidocs URL 分流决策」**,按规则识别 URL 类型;仅当确认是在线电子表格(`/spreadsheetv2/...` 或 `i/nodes/` 且 probe 出 `contentType=ALIDOC` + `extension=axls`)时,才继续走本 skill 的命令。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "创建电子表格" | `dws sheet create --name "<标题>"` | +| "新建工作表" | `dws sheet new --node --name ""` | +| "读取单元格" | `dws sheet range read --node --sheet-id --range A1:B2` | +| "写入单元格" | `dws sheet range update --node --sheet-id --range A1:B2 --values '[[..]]'` | +| "追加一行" | `dws sheet append --node --sheet-id --values '[[..]]'` | +| "查找 / 替换" | `dws sheet find --node --sheet-id --find "<关键词>"` / `dws sheet replace --node --sheet-id --find "<旧值>" --replacement "<新值>"` | +| "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --resource-id `(或 `--resource-url `) | + +## URL 与 ID 前置 + +- 用户直接粘贴 `alidocs.dingtalk.com` URL 时,先执行 `dws doc info --node "" --format json` 探测;只有 `contentType=ALIDOC` 且 `extension=axls` 才继续走 `sheet`。 +- `spreadsheetv2` 链接不要截取短 path segment,必须把完整 URL 原样传给 `--node`。 +- 写入类命令必须先用 `dws sheet list --node --format json` 取得真实 `sheetId`;不要猜 `Sheet1`、`sheet1`、`0`。 +- 所有 sheet 子命令使用 `--node` 参数;不要写 `--node-id`、`--file-id` 或把 JSON 字段名 `id` 当成节点值传入。 + +## 评测高频硬约束 + +- `sheet create` 后必须从返回结果提取真实 `nodeId` 或文档 URL,后续 `range update/read` 原样传给 `--node`;如果返回里同时有 `nodeId` 和 `url`,优先用 `nodeId`。 +- 写入区域前先 `sheet list --node --format json` 获取真实 `sheetId`;写入后立即 `range read` 同一区域验证。 +- `range update` 的 `--values` 必须是二维 JSON,行列数量与 `--range` 完全一致;批量样式用 `range batch-set-style --batch `。 +- 同一参数错误最多纠正 2 次;若持续 `nodeId 格式不合法`,回到 `doc info --node ` 或 `sheet create` 返回重新取 ID,不要无意义重试。 + +## 跨产品协作 + +- 多维表 / 字段类型 → 切到 `dingtalk-aitable` +- 把表数据写进文档 → 切到 `dingtalk-doc` diff --git a/skills/multi/dingtalk-sheet/references/sheet.md b/skills/multi/dingtalk-sheet/references/sheet.md new file mode 100644 index 00000000..e2d65164 --- /dev/null +++ b/skills/multi/dingtalk-sheet/references/sheet.md @@ -0,0 +1,1261 @@ +# 电子表格 (sheet) 命令参考 + +> ⚠️ **CLI 暴露状态(v1.0.30)**:本文档部分章节描述的命令尚未在开源 CLI 暴露。当前 v1.0.30 可调用:`create / new / list / info / range read / range update / append / find / replace / add-dimension / insert-dimension / delete-dimension / move-dimension / update-dimension / merge-cells / unmerge-cells / write-image / filter-view (create / list / update / delete / update-criteria / delete-criteria)`。**尚未暴露**:`copy / update / export / media-upload / create-float-image / get-float-image / list-float-images / update-float-image / delete-float-image / set-dropdown / get-dropdown / delete-dropdown / range set-style / range batch-set-style / filter-view info / filter-view list-criteria / filter-view get-criteria`,跑这些命令会 fall back 到 `dws sheet` 帮助页面,请勿当作可用接口调用。文档保留是因为它们是已规划的能力。 + +## 适用范围(重要) + +`sheet` 产品线仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`),不支持上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。 + +| 文件类型 | 处理方式 | +|---------|---------| +| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) | +| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node --output <路径>` 先下载到本地再解析处理,禁止调用任何 `sheet` 子命令(sheet 底层 MCP 工具仅认 `axls`,传入 xlsx 节点会直接报错) | +| 想把在线表格导出为 xlsx | 开源 v1.0.30 暂不支持 CLI 导出,请在钉钉客户端导出 | + +> 用户直接粘贴原始 `alidocs` URL 时必须先 probe:先执行 `dws doc info --node --format json`,按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」校验 `contentType` 和 `extension`: +> - 仅当 `contentType=ALIDOC` 且 `extension=axls` 时,才继续走 `sheet` +> - 如果是 `xlsx` / `xls` / `xlsm` / `csv`,立即转向 `dws doc download`,并告知用户"这是本地表格文件,已为你下载到本地处理" + +## URL 识别与 NODE_ID 提取 + +当用户输入包含钉钉文档 URL 时,必须先识别并提取 NODE_ID,再判断意图。 + +硬性规则:对用户直接给出的原始 `alidocs` URL,不允许直接走 `sheet` 命令,必须先执行: + +```bash +dws doc info --node "" --format json +``` + +根据返回路由: +- `contentType=ALIDOC` + `extension=axls` → 继续走 `sheet` +- `contentType≠ALIDOC` + `extension=xlsx` / `xls` / `xlsm` / `csv` → 转向 `dws doc download --node --output <路径>`,禁止调用任何 sheet 子命令 +- 其他类型 → 按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」路由 + +补充:如果这是用户直接提供的原始 `alidocs` URL,先按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」probe 一次确认真实类型,再判断是否继续走 `sheet`。 + +### 支持的 URL 格式 + +| 格式 | 示例 | NODE_ID 提取方式 | +|------|------|----------------| +| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段 | +| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段 | +| `alidocs.dingtalk.com/spreadsheetv2/{key}/...` | `https://alidocs.dingtalk.com/spreadsheetv2/vKJngl50tJN1v3a3/...?dentryKey=vKJngl50tJN1v3a3&type=s` | **不要提取 path segment**,将完整 URL 直接传给 `--node` 参数,由 MCP 服务端解析 | + +### 提取规则 + +1. 匹配 URL 中 `alidocs.dingtalk.com` 域名 +2. **判断 URL 路径格式**: + - 路径包含 `/i/nodes/` → 取 URL path 的最后一段作为 NODE_ID(去掉 query string 和 fragment) + - 路径包含 `/spreadsheetv2/` → **不要提取 path segment**,必须将完整 URL 原样传给 `--node` 参数(因为 path 中的短 ID 不是合法的 nodeId,MCP 服务端会自行解析完整 URL) +3. 对于 `/i/nodes/` 格式,提取出的 NODE_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析) +4. 对用户直接提供的原始 `alidocs` URL,先按 [url-patterns.md](./url-patterns.md) 的「alidocs URL 类型探测流程」probe;只有 probe 确认 `contentType=ALIDOC` 且 `extension=axls` 时,才继续留在 `sheet`;如果 `extension=xlsx` / `xls` / `xlsm` / `csv`,必须转向 `dws doc download`,不能走任何 sheet 命令 + +## 查询命令帮助 + +当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 + +```bash +# 查看 sheet 下所有子命令 +dws sheet --help + +# 查看具体命令的完整参数说明 +dws sheet range update --help +dws sheet filter-view create --help +dws sheet insert-dimension --help + +# 查看子命令组下的所有命令 +dws sheet range --help +dws sheet filter-view --help +``` + +规则: +- 参数名不确定时 → 先 `--help`,再调用 +- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 +- 不确定某个功能是否存在时 → `dws sheet --help` 查看命令列表 + +## 命令速查表 + +| 命令 | 用途 | +|------|------| +| `sheet create` | 创建钉钉表格文档 | +| `sheet list` | 获取全部工作表列表 | +| `sheet info` | 获取指定工作表详情 | +| `sheet new` | 新建工作表 | +| `sheet update` | 更新工作表属性(标题/位置/隐藏/冻结) | +| `sheet copy` | 复制工作表 | +| `sheet range read` | 读取工作表数据(别名: range get) | +| `sheet range update` | 更新指定区域内容(值/公式/超链接) | +| `sheet range set-style` | 设置单元格样式 | +| `sheet range batch-set-style` | 按配置文件批量设置样式 | +| `sheet find` | 搜索单元格内容 | +| `sheet append` | 在末尾追加数据行 | +| `sheet replace` | 全局查找替换文本 | +| `sheet merge-cells` | 合并单元格 | +| `sheet unmerge-cells` | 取消合并单元格 | +| `sheet set-dropdown` | 设置下拉列表 | +| `sheet get-dropdown` | 获取下拉列表配置 | +| `sheet delete-dropdown` | 删除下拉列表 | +| `sheet insert-dimension` | 在指定位置插入行或列 | +| `sheet delete-dimension` | 删除指定位置的行或列 | +| `sheet update-dimension` | 更新行/列属性(显隐/行高/列宽) | +| `sheet move-dimension` | 移动行或列到指定位置 | +| `sheet add-dimension` | 在末尾追加空行或空列 | +| `sheet media-upload` | 上传附件到表格 | +| `sheet write-image` | 上传图片并写入单元格 | +| `sheet create-float-image` | 创建浮动图片 | +| `sheet get-float-image` | 获取浮动图片详情 | +| `sheet list-float-images` | 列出工作表所有浮动图片 | +| `sheet update-float-image` | 更新浮动图片属性 | +| `sheet delete-float-image` | 删除浮动图片 | +| `sheet filter-view list` | 获取所有筛选视图 | +| `sheet filter-view create` | 创建筛选视图 | +| `sheet filter-view update` | 更新筛选视图属性 | +| `sheet filter-view delete` | 删除筛选视图 | +| `sheet filter-view update-criteria` | 更新筛选视图列条件 | +| `sheet filter-view delete-criteria` | 删除筛选视图列条件 | +| `sheet filter-view info` | 获取单个筛选视图详情 | +| `sheet filter-view list-criteria` | 列出筛选视图所有列条件 | +| `sheet filter-view get-criteria` | 获取单列筛选条件详情 | + +> 不确定参数?对任意命令执行 `dws sheet <命令> --help` 查看完整用法。 + +## 意图判断 + +### 表格与工作表管理 + +用户说"创建表格/新建电子表格": +- 创建表格文档 → `create` + +用户说"看工作表/有哪些工作表/表格结构": +- 列出工作表 → `list` +- 工作表详情 → `info` + +用户说"加工作表/新增Sheet": +- 新建工作表 → `new` + +用户说"修改工作表名称/重命名工作表/移动工作表位置/隐藏工作表/显示工作表/冻结行/冻结列/取消冻结/更新工作表属性": +- 更新工作表属性 → `update` +- 重命名工作表 → `update --title "新名称"` +- 移动工作表位置 → `update --index N` +- 隐藏工作表 → `update --hidden` +- 显示工作表 → `update --hidden=false` +- 冻结行列 → `update --frozen-row-count N --frozen-column-count M` +- 取消冻结 → `update --frozen-row-count 0 --frozen-column-count 0` + +用户说"复制工作表/拷贝工作表/克隆工作表/工作表副本": +- 复制工作表 → `copy` +- 复制并指定名称 → `copy --title "副本名称"` +- 复制并指定位置 → `copy --index N` + +### 数据读写 + +用户说"读数据/看表格内容": +- 读取数据 → `range read` + +用户说"写数据/填表/更新单元格/写入公式": +- 更新数据 → `range update` +- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等 +- 注意:如果用户的目的是替换文本、移动行列或追加空行空列,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`) + +用户说"追加数据/添加行/在末尾加数据/新增记录": +- 追加数据 → `append` + +用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配": +- 搜索单元格 → `find` +- 精确匹配(只匹配完全等于的,不匹配包含的) → `find --match-entire-cell` +- 正则搜索 → `find --use-regexp` +- 搜索公式 → `find --match-formula` +- 不要用 `range read` 读取全量数据后在客户端过滤来替代 `find`,必须使用 `find` 命令的服务端搜索能力 + +用户说"替换/查找替换/全局替换/批量替换/把A替换成B/把所有的X改成Y": +- 查找替换 → `replace` +- 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell` +- 正则替换 → `replace --use-regexp` +- 删除匹配内容 → `replace --replacement ""` +- 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数 + +### 行列操作 + +用户说"插入行/插入列/在某行前插入/在某列前插入": +- 插入行或列 → `insert-dimension` +- 在末尾追加 → `append`(insert-dimension 不支持末尾追加) + +用户说"删除行/删除列/删掉第几行/删掉某列/移除行/移除列": +- 删除行或列 → `delete-dimension` +- 仅清空内容但保留行/列 → `range update --values` 写入空字符串 `""` + +用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性": +- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false` +- 设置行高/列宽 → `update-dimension --pixel-size` +- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden` + +用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行": +- 移动行或列 → `move-dimension` +- 请勿用 `range read` + `range update` 读取再重写来模拟移动,`move-dimension` 是原子操作,能保留格式和合并状态 + +用户说"追加空行/追加空列/增加行数/增加列数/扩展表格/在末尾加空行": +- 追加空行/空列 → `add-dimension` +- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 +- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 + +### 筛选视图 + +用户说"筛选视图/查看筛选视图/有哪些筛选视图/筛选视图列表": +- 获取所有筛选视图 → `filter-view list` + +用户说"筛选视图详情/查看某个筛选视图/筛选视图信息/筛选视图配置": +- 获取单个筛选视图详情 → `filter-view info` + +用户说"创建筛选视图/新建筛选视图/添加筛选视图": +- 创建筛选视图 → `filter-view create` + +用户说"更新筛选视图/修改筛选视图/改筛选视图名称/改筛选视图范围": +- 更新筛选视图属性 → `filter-view update` + +用户说"删除筛选视图/移除筛选视图": +- 删除筛选视图 → `filter-view delete` + +用户说"设置筛选条件/添加筛选条件/配置筛选视图条件/按值筛选/按条件筛选/按颜色筛选": +- 设置筛选视图列条件 → `filter-view update-criteria` + +用户说"查看筛选条件/有哪些筛选条件/筛选视图设了什么条件/列出筛选条件": +- 列出所有列条件 → `filter-view list-criteria` +- 查看某一列的条件 → `filter-view get-criteria --column N` + +用户说"清除筛选条件/移除筛选条件/取消筛选条件": +- 清除筛选视图列条件 → `filter-view delete-criteria` +- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身 + +### URL 粘贴场景 + +用户直接粘贴表格 URL(无其他指令): +- 先 probe:`dws doc info --node --format json` 校验 `contentType` 和 `extension` +- `extension=axls` → `list`(列出工作表)+ `range read`(读取第一个工作表数据) +- `extension=xlsx` / `xls` / `xlsm` / `csv` → 转 `dws doc download --node --output ./`,告知用户"这是本地表格文件,已为你下载到本地",然后基于本地文件继续后续处理 + +用户粘贴 URL + 附加指令: +- 已 probe 为 `axls` 时: + - "帮我看看这个表格有什么数据" → `range read` + - "这个表格有哪些工作表" → `list` + - "往这个表格写入数据" → `range update` + - "帮我找一下表格里的XXX" → `find` +- probe 为 xlsx/xls/xlsm/csv 时:无论用户说"读数据/查看/分析",先走 `dws doc download` 下载到本地,由用户或后续步骤对本地 xlsx 进行解析,严禁调用 `sheet list` / `range read` 等命令 + +关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录) vs doc(文档编辑/阅读) + +## 关键注意事项 + +以下是最容易出错的规则,**必须严格遵守**: + +- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) +- ★ **`range update` 维度校验(强制)**:`--values` / `--hyperlinks` 的行列数必须与 `--range` 完全一致。例如 `--range "A1:C3"` → `--values` 必须是 3×3 数组 +- ★ **`range update` 清空规范(强制)**:清空单元格用空字符串 `""`,禁止用 `null`(全 null 会被跳过无效果) +- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) +- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤 +- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数 +- ★ **移动用 `move-dimension` 不用 `range update`**:原子操作,保留格式和合并状态 +- ★ **单元格图片用 `write-image` 不用 `range update`**:`update_range` MCP 不支持图片参数,调用必失败 +- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` +- ★ **`export` 禁止自行轮询/重试**:CLI 内部已完成渐进式退避轮询(最多 30 次约 5 分钟),失败时直接告知用户稍后再试 +- ★ **关键区分**:sheet(在线电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读) + +> 完整注意事项请参见本文档末尾「注意事项(完整版)」章节。 + +## 命令详细参考 + +> 以下为各命令的完整 Usage、Flags 和示例。参数不确定时也可直接执行 `dws sheet <命令> --help` 在线查看。 + +### 创建钉钉表格文档 +``` +Usage: + dws sheet create [flags] +Example: + dws sheet create --name "销售数据" + dws sheet create --name "Q1 数据" --folder + dws sheet create --name "知识库表格" --workspace +Flags: + --name string 表格名称 (必填) + --folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId + --workspace string 目标知识库 ID +``` + +> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 + +### 获取全部工作表列表 +``` +Usage: + dws sheet list [flags] +Example: + dws sheet list --node + dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/" +Flags: + --node string 表格文档 ID 或 URL (必填) +``` + +### 获取指定工作表详情 +``` +Usage: + dws sheet info [flags] +Example: + dws sheet info --node + dws sheet info --node --sheet-id + dws sheet info --node --sheet-id "Sheet1" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表) +``` + +### 新建工作表 +``` +Usage: + dws sheet new [flags] +Example: + dws sheet new --node --name "Sheet2" + dws sheet new --node --name "数据汇总" +Flags: + --node string 表格文档 ID (必填) + --name string 工作表名称 (必填) +``` + +### 读取工作表数据 +``` +Usage: + dws sheet range read [flags] # 别名: dws sheet range get +Example: + dws sheet range read --node + dws sheet range read --node --sheet-id + dws sheet range read --node --sheet-id "Sheet1" --range "A1:D10" + dws sheet range read --node --range "Sheet1!A1:D10" + + # 使用 get 别名,与 read 等价 + dws sheet range get --node --sheet-id --range "A1:D10" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表) + --range string 读取范围,A1 表示法 (如 A1:D10,不传则读取全部数据) +``` + +**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: +- 先通过 `info` 获取 `rowCount` / `lastNonEmptyRow` / `columnCount` 确定数据边界 +- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… +- 避免不传 `--range` 直接读取整个大工作表 + +### 更新工作表指定区域内容 +``` +Usage: + dws sheet range update [flags] +Example: + # 写入值 + dws sheet range update --node --sheet-id --range "A1:B2" \ + --values '[["姓名","分数"],["张三",90]]' + + # 写入公式 + dws sheet range update --node --sheet-id --range "C2" \ + --values '[["=A2&B2"]]' + + # 写入超链接 + dws sheet range update --node --sheet-id --range "A1" \ + --hyperlinks '[[{"type":"path","link":"https://dingtalk.com","text":"钉钉"}]]' + + # 清空区域(使用空字符串 "") + dws sheet range update --node --sheet-id --range "A1:B3" \ + --values '[["",""],["",""],["",""]]' +Flags: + --node string 表格文档 ID (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格区域地址,如 A1:B3 (必填) + --values string 单元格值,二维 JSON 数组 (与 --hyperlinks 至少传一项) + --hyperlinks string 超链接,二维 JSON 数组 (与 --values 至少传一项) + --number-format string 数字格式,如 General/@/#,##0/0%/yyyy/m/d 等 +``` + +**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。 + +### 在工作表中搜索单元格内容 +``` +Usage: + dws sheet find [flags] +Example: + # 基本搜索 + dws sheet find --node --sheet-id --find "销售额" + + # 在指定范围内搜索 + dws sheet find --node --sheet-id --find "合计" --range "A1:D100" + + # 正则表达式搜索(不区分大小写) + dws sheet find --node --sheet-id --find "^total" --use-regexp --match-case=false + + # 精确匹配整个单元格内容 + dws sheet find --node --sheet-id --find "完成" --match-entire-cell + + # 搜索公式文本 + dws sheet find --node --sheet-id --find "SUM" --match-formula +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --find string 搜索文本 (必填) + --range string 搜索范围,A1 表示法 (如 A1:D10) + --match-case 区分大小写 (默认 true) + --match-entire-cell 精确匹配整个单元格内容 + --use-regexp 启用正则表达式搜索 + --match-formula 搜索公式文本而非显示值 + --include-hidden 包含隐藏单元格 +``` + +### 在工作表末尾追加数据 +``` +Usage: + dws sheet append [flags] +Example: + dws sheet append --node --sheet-id --values '[["张三","销售部",50000]]' + dws sheet append --node --sheet-id "Sheet1" \ + --values '[["李四","市场部",38000],["王五","销售部",62000]]' +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --values string 追加数据,二维 JSON 数组 (必填) +``` + +`--values` 为二维 JSON 数组,外层每个元素代表一行,内层每个元素代表一个单元格值。 +追加的数据列数应与工作表已有数据的列数保持一致。 + +### 在指定位置插入行或列 +``` +Usage: + dws sheet insert-dimension [flags] +Example: + # 在第 3 行之前插入 2 行 + dws sheet insert-dimension --node --sheet-id --dimension ROWS --position "3" --length 2 + + # 在 A 列之前插入 1 列 + dws sheet insert-dimension --node --sheet-id --dimension COLUMNS --position "A" --length 1 + + # 使用工作表前缀(忽略 --sheet-id) + dws sheet insert-dimension --node --sheet-id --dimension ROWS --position "Sheet1!3" --length 5 + + # 在 AB 列之前插入 3 列 + dws sheet insert-dimension --node --sheet-id --dimension COLUMNS --position "AB" --length 3 +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --dimension string 插入维度: ROWS 或 COLUMNS (必填) + --position string 插入位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" + --length string 插入数量,正整数 (必填),最大 5000 +``` + +在钉钉表格指定工作表的指定位置之前插入若干空行或空列。 +`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。 +支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 +若需要在末尾追加行/列,请使用 `append` 命令。 + +### 删除指定位置的行或列 +``` +Usage: + dws sheet delete-dimension [flags] +Example: + # 从第 3 行开始删除 2 行 + dws sheet delete-dimension --node --sheet-id --dimension ROWS --position "3" --length 2 + + # 从 A 列开始删除 1 列 + dws sheet delete-dimension --node --sheet-id --dimension COLUMNS --position "A" --length 1 + + # 使用工作表前缀(忽略 --sheet-id) + dws sheet delete-dimension --node --sheet-id --dimension ROWS --position "Sheet1!3" --length 5 + + # 从 AB 列开始删除 3 列 + dws sheet delete-dimension --node --sheet-id --dimension COLUMNS --position "AB" --length 3 +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --dimension string 删除维度: ROWS 或 COLUMNS (必填) + --position string 删除起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" + --length string 删除数量,正整数 (必填),最大 5000 +``` + +在钉钉表格指定工作表中,从指定位置起删除若干连续的行或列。 +`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。 +支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 +删除后后续的行/列会向前移动填补空位;若需要仅清空内容但保留行/列占位,请使用 `range update` 将目标区域写入空字符串 `""`。 + +### 更新指定范围行/列属性 +``` +Usage: + dws sheet update-dimension [flags] +Example: + # 隐藏第 3~4 行 + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "3" --length 2 --hidden + + # 显示 A~B 列 + dws sheet update-dimension --node --sheet-id --dimension COLUMNS --start-index "A" --length 2 --hidden=false + + # 设置第 1~5 行行高为 40px + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "1" --length 5 --pixel-size 40 + + # 设置 C 列列宽为 200px 并隐藏 + dws sheet update-dimension --node --sheet-id --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden + + # 使用工作表前缀(忽略 --sheet-id) + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --dimension string 更新维度: ROWS 或 COLUMNS (必填) + --start-index string 起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" + --length string 更新数量,正整数 (必填),最大 5000 + --hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一 + --pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一 +``` + +批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。 +`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。 +支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 +`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 +`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。 + +### 合并单元格 +``` +Usage: + dws sheet merge-cells [flags] +Example: + # 合并所有单元格(默认) + dws sheet merge-cells --node --sheet-id --range "A1:B3" + + # 按行合并 + dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeRows + + # 按列合并 + dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeColumns + + # 使用带工作表前缀的范围(忽略 --sheet-id) + dws sheet merge-cells --node --sheet-id --range "Sheet1!A1:B3" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格区域地址,如 A1:B3 (必填) + --merge-type string 合并方式: mergeAll(默认)/mergeRows/mergeColumns +``` + +支持三种合并方式: +- `mergeAll`(默认):合并所有单元格,将选定区域内的所有单元格合并成一个 +- `mergeRows`:按行合并,在选定区域内将同一行相邻的单元格合并 +- `mergeColumns`:按列合并,在选定区域内将同一列相邻的单元格合并 + +注意:合并时只保留左上角单元格的值,其他单元格的值会被丢弃。 +`--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时将优先使用前缀解析出的工作表,忽略 `--sheet-id`。 + +### 全局查找替换 +``` +Usage: + dws sheet replace [flags] +Example: + dws sheet replace --node --sheet-id --find "旧文本" --replacement "新文本" + dws sheet replace --node --sheet-id --find "待处理" --replacement "已完成" --match-entire-cell + dws sheet replace --node --sheet-id --find "\\d{4}" --replacement "****" --use-regexp + dws sheet replace --node --sheet-id --find "旧" --replacement "新" --range "A1:D100" + dws sheet replace --node --sheet-id --find "临时" --replacement "" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --find string 查找文本 (必填) + --replacement string 替换文本 (必填,可为空字符串表示删除) + --range string 替换范围,A1 表示法 (如 A1:D100) + --match-case 区分大小写 (默认 false) + --match-entire-cell 完整单元格匹配 + --use-regexp 启用正则表达式匹配 + --include-hidden 包含隐藏行/列 +``` + +返回被替换的单元格数量。`--replacement` 可以为空字符串,表示删除匹配内容。 + +### 移动行或列 +``` +Usage: + dws sheet move-dimension [flags] +Example: + # 将第 2 行移动到第 5 行的位置(索引从 0 开始) + dws sheet move-dimension --node --sheet-id \ + --dimension ROWS --start-index 1 --end-index 1 --destination-index 4 + + # 将第 2~4 行移动到第 1 行之前 + dws sheet move-dimension --node --sheet-id \ + --dimension ROWS --start-index 1 --end-index 3 --destination-index 0 + + # 将 B~C 列移动到 E 列的位置 + dws sheet move-dimension --node --sheet-id \ + --dimension COLUMNS --start-index 1 --end-index 2 --destination-index 4 +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --dimension string 维度类型: ROWS 或 COLUMNS (必填) + --start-index int 源起始索引,0-based (必填) + --end-index int 源结束索引,0-based,包含 (必填) + --destination-index int 目标位置索引,0-based (必填) +``` + +索引均为 0-based(第 1 行/列的索引为 0)。destination-index 不能在 [start-index, end-index] 范围内。 + +**destination-index 计算规则:** +destination-index 是目标位置的 0-based 索引,即移动到第 n 行/列则传 n-1: +- 通用公式:`destination-index = 目标行号(1-based) - 1` +- 例如:将第 2 行移到第 5 行位置 → `destination-index = 5 - 1 = 4`,即 `start-index=1, end-index=1, destination-index=4` +- 例如:将第 4 行移到第 1 行(最前面)→ `destination-index = 1 - 1 = 0`,即 `start-index=3, end-index=3, destination-index=0` + +### 追加空行或空列 +``` +Usage: + dws sheet add-dimension [flags] +Example: + dws sheet add-dimension --node --sheet-id --dimension ROWS --length 5 + dws sheet add-dimension --node --sheet-id --dimension COLUMNS --length 3 +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --dimension string 维度类型: ROWS 或 COLUMNS (必填) + --length int 追加数量,正整数,最多 5000 (必填) +``` + +在工作表末尾追加指定数量的空行或空列。 + +### 取消合并单元格 +``` +Usage: + dws sheet unmerge-cells [flags] +Example: + dws sheet unmerge-cells --node --sheet-id --range "A1:D5" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 取消合并的范围,A1 表示法 (必填) +``` + +取消指定范围内所有合并的单元格,恢复为独立单元格。 + +### 获取所有筛选视图 +``` +Usage: + dws sheet filter-view list [flags] +Example: + dws sheet filter-view list --node --sheet-id + dws sheet filter-view list --node "https://alidocs.dingtalk.com/i/nodes/" --sheet-id "Sheet1" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) +``` + +获取指定工作表的所有筛选视图列表,返回每个筛选视图的 ID、名称和范围信息。 +- **用途**:查看当前工作表上已创建的所有筛选视图,获取视图 ID、名称和范围。 +- **场景**:在对筛选视图进行 update / delete / update-criteria 等操作前,先用 list 获取可用的 filterViewId。 +- **区分**:筛选视图(filter-view)是个人化的数据过滤方式,与全局筛选不同。每个用户可以创建自己的筛选视图,互不影响原始数据。如果没有筛选视图,返回空列表。 + +### 创建筛选视图 +``` +Usage: + dws sheet filter-view create [flags] +Example: + # 创建不带筛选条件的筛选视图 + dws sheet filter-view create --node --sheet-id --name "我的视图" --range "A1:E10" + + # 创建带按值筛选条件的筛选视图 + dws sheet filter-view create --node --sheet-id --name "销售筛选" --range "A1:E10" \ + --criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]}]' + + # 创建带按条件筛选的筛选视图(大于等于 200000) + dws sheet filter-view create --node --sheet-id --name "高预算" --range "A1:C10" \ + --criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}]' +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --name string 筛选视图名称 (必填) + --range string 筛选视图范围,A1 表示法,如 A1:E10 (必填) + --criteria string 筛选条件,JSON 数组 (可选) +``` + +在指定工作表中创建一个筛选视图。 +- **用途**:为指定数据区域创建一个可命名的个人化筛选视图,可选同时设置筛选条件。 +- **场景**:用户需要针对某个数据区域建立固定的筛选视角(如"高绩效员工""研发部数据"),方便反复查看。 +- **区分**:与全局筛选不同,筛选视图是个人化的,不影响其他用户看到的数据。如果只需创建视图不设条件,后续可通过 `update-criteria` 单独设置;如果要一步到位,可通过 `--criteria` 在创建时直接设置。 +`--criteria` 为 JSON 数组,每个元素包含 `column`(列偏移量,从 0 开始)和筛选条件字段。支持三种筛选类型: +- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表 +- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符(kebab-case):`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或) +- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一 + +### 更新筛选视图属性 +``` +Usage: + dws sheet filter-view update [flags] +Example: + # 更新筛选视图名称 + dws sheet filter-view update --node --sheet-id --filter-view-id --name "新名称" + + # 更新筛选视图范围 + dws sheet filter-view update --node --sheet-id --filter-view-id --range "A1:F20" + + # 更新筛选条件 + dws sheet filter-view update --node --sheet-id --filter-view-id \ + --criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]' +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --filter-view-id string 筛选视图 ID (必填) + --name string 筛选视图新名称 + --range string 筛选视图新范围,A1 表示法 + --criteria string 筛选条件,JSON 数组 +``` + +更新筛选视图的名称、范围和/或筛选条件,`--name`、`--range`、`--criteria` 至少传入一个。 +- **用途**:修改已有筛选视图的名称、数据范围或筛选条件。 +- **场景**:数据区域扩展后需要扩大筛选视图范围,或重命名视图,或通过 `--criteria` 一次性批量更新多列筛选条件。 +- **区分**:`update` 可同时修改名称、范围和条件,适合批量更新;`update-criteria` 只能设置单列条件,适合精确控制某一列的筛选逻辑。`--criteria` 指定列的条件会被替换,未指定的列保持不变。 + +`--criteria` 为 JSON 数组,格式与 `filter-view create` 的 `--criteria` 相同,支持的筛选类型和操作符参见「创建筛选视图」说明。 + +### 删除筛选视图 +``` +Usage: + dws sheet filter-view delete [flags] +Example: + dws sheet filter-view delete --node --sheet-id --filter-view-id +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --filter-view-id string 筛选视图 ID (必填) +``` + +删除指定的筛选视图。 +- **用途**:永久删除一个不再需要的筛选视图及其所有筛选条件。 +- **场景**:筛选视图已过时或不再需要时,清理无用的视图。 +- **区分**:`delete` 删除整个筛选视图(包括所有列的条件),操作不可恢复;`delete-criteria` 只删除某一列的筛选条件,视图本身保留。此操作不影响全局筛选或其他筛选视图,也不影响原始数据。 + +### 更新筛选视图列条件 +``` +Usage: + dws sheet filter-view update-criteria [flags] +Example: + # 按值筛选:只显示"销售部"和"市场部" + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' + + # 按条件筛选:大于 100 + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' + + # 按条件筛选:大于等于 200000 + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}' + + # 按条件筛选:小于 100 + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"less","value":"100"}]}' + + # 多条件筛选:大于等于 60 且 小于等于 90 + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 2 --filter-criteria '{"filterType":"condition","conditionOperator":"and","conditions":[{"operator":"greater-equal","value":"60"},{"operator":"less-equal","value":"90"}]}' + + # 按颜色筛选:背景色为红色 + dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 1 --filter-criteria '{"filterType":"color","backgroundColor":"#FF0000"}' +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --filter-view-id string 筛选视图 ID (必填) + --column int 列偏移量,从 0 开始 (必填) + --filter-criteria string 筛选条件,JSON 对象 (必填) +``` + +更新筛选视图中某一列的筛选条件。 +- **用途**:为筛选视图的指定列创建或更新筛选条件,控制该列哪些数据行可见。 +- **场景**:只显示某些特定值的行(如"只看研发部")→ `filterType: values`;按数值条件筛选(如"绩效 ≥ 85")→ `filterType: condition` + `operator: greater-equal`;按文本条件筛选(如"名称包含关键字")→ `filterType: condition` + `operator: contains`。 +- **区分**:`update-criteria` 精确控制单列条件,适合逐列设置不同的筛选逻辑;`filter-view update --criteria` 可以批量更新多列条件;`delete-criteria` 是 `update-criteria` 的逆操作,删除指定列的条件。 + +`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。 +例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列,`--column 1` 代表 C 列。 + +`--filter-criteria` 为 JSON 对象,支持三种筛选类型: +- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表 +- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符:`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或) +- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一 + +### 删除筛选视图列条件 +``` +Usage: + dws sheet filter-view delete-criteria [flags] +Example: + # 删除第 1 列(A 列)的筛选条件 + dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id --column 0 + + # 删除第 3 列(C 列)的筛选条件 + dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id --column 2 +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --filter-view-id string 筛选视图 ID (必填) + --column int 列偏移量,从 0 开始 (必填) +``` + +清除筛选视图中指定列的筛选条件。 +- **用途**:移除筛选视图中指定列的筛选条件,使该列不再参与过滤。 +- **场景**:之前通过 `update-criteria` 设置了某列的筛选条件,现在需要取消该列的筛选以显示全部数据。 +- **区分**:`delete-criteria` 只清除指定列的条件,筛选视图本身和其他列的条件保持不变;`delete` 会删除整个筛选视图。如果指定列没有设置筛选条件,调用此命令不会报错(幂等操作)。 + +## 核心工作流 + +```bash +# ── 工作流 1: 创建表格并写入数据 ── + +# 1. 创建表格文档 — 提取 nodeId +dws sheet create --name "销售数据" --format json + +# 2. 查看工作表列表 — 提取 sheetId +dws sheet list --node --format json + +# 3. 写入表头和数据 +dws sheet range update --node --sheet-id --range "A1:C1" \ + --values '[["姓名","部门","销售额"]]' --format json + +dws sheet range update --node --sheet-id --range "A2:C4" \ + --values '[["张三","销售部",50000],["李四","市场部",38000],["王五","销售部",62000]]' --format json + +# ── 工作流 2: 读取已有表格数据 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 查看工作表详情(行列数、最后非空位置等) +dws sheet info --node --sheet-id --format json + +# 3. 读取全部数据 +dws sheet range read --node --sheet-id --format json + +# 4. 读取指定区域 +dws sheet range read --node --sheet-id --range "A1:D10" --format json + +# ── 工作流 3: 多工作表管理 ── + +# 1. 新建工作表 +dws sheet new --node --name "汇总" --format json + +# 2. 在新工作表中写入汇总公式 +dws sheet range update --node --sheet-id --range "A1:B1" \ + --values '[["指标","数值"]]' --format json + +dws sheet range update --node --sheet-id --range "A2:B2" \ + --values '[["总销售额","=SUM(Sheet1!C2:C100)"]]' --format json + +# ── 工作流 4: 批量更新与格式化 ── + +# 1. 写入数据 +dws sheet range update --node --sheet-id --range "A1:C3" \ + --values '[["商品","单价","数量"],["苹果",5.5,100],["香蕉",3.2,200]]' --format json + +# 2. 设置数字格式(人民币) +dws sheet range update --node --sheet-id --range "B2:B3" \ + --values '[[5.5],[3.2]]' --number-format "¥#,##0.00" --format json + +# 3. 写入超链接 +dws sheet range update --node --sheet-id --range "D1" \ + --hyperlinks '[[{"type":"path","link":"https://dingtalk.com","text":"详情"}]]' --format json + +# ── 工作流 5: 追加数据 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 查看工作表详情(确认列结构) +dws sheet info --node --sheet-id --format json + +# 3. 追加单行数据 +dws sheet append --node --sheet-id \ + --values '[["张三","销售部",50000]]' --format json + +# 4. 追加多行数据 +dws sheet append --node --sheet-id \ + --values '[["李四","市场部",38000],["王五","销售部",62000]]' --format json +``` + +```bash +# ── 工作流 6: 插入行或列 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 在第 3 行之前插入 2 行 +dws sheet insert-dimension --node --sheet-id \ + --dimension ROWS --position "3" --length 2 --format json + +# 3. 在 A 列之前插入 1 列 +dws sheet insert-dimension --node --sheet-id \ + --dimension COLUMNS --position "A" --length 1 --format json + +# 4. 使用工作表前缀指定位置 +dws sheet insert-dimension --node --sheet-id \ + --dimension ROWS --position "Sheet1!5" --length 3 --format json +``` + +```bash +# ── 工作流 6b: 删除行或列 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 从第 3 行开始删除 2 行 +dws sheet delete-dimension --node --sheet-id \ + --dimension ROWS --position "3" --length 2 --format json + +# 3. 从 A 列开始删除 1 列 +dws sheet delete-dimension --node --sheet-id \ + --dimension COLUMNS --position "A" --length 1 --format json + +# 4. 使用工作表前缀指定位置 +dws sheet delete-dimension --node --sheet-id \ + --dimension ROWS --position "Sheet1!5" --length 3 --format json +``` + +```bash +# ── 工作流 6c: 更新行/列属性(显隐、行高/列宽) ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 隐藏第 3~4 行 +dws sheet update-dimension --node --sheet-id \ + --dimension ROWS --start-index "3" --length 2 --hidden --format json + +# 3. 显示 A~B 列 +dws sheet update-dimension --node --sheet-id \ + --dimension COLUMNS --start-index "A" --length 2 --hidden=false --format json + +# 4. 设置第 1~5 行行高为 40px +dws sheet update-dimension --node --sheet-id \ + --dimension ROWS --start-index "1" --length 5 --pixel-size 40 --format json + +# 5. 设置 C 列列宽为 200px 并隐藏 +dws sheet update-dimension --node --sheet-id \ + --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden --format json +``` + +```bash +# ── 工作流 7: 搜索表格数据 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 基本搜索 — 在指定工作表中查找文本 +dws sheet find --node --sheet-id --find "销售额" --format json + +# 3. 在指定范围内搜索 +dws sheet find --node --sheet-id --find "合计" --range "A1:D100" --format json + +# 4. 正则搜索(不区分大小写) +dws sheet find --node --sheet-id --find "^total" --use-regexp --match-case=false --format json + +# 5. 精确匹配整个单元格 +dws sheet find --node --sheet-id --find "完成" --match-entire-cell --format json + +# 6. 搜索公式文本 +dws sheet find --node --sheet-id --find "SUM" --match-formula --format json +``` + +```bash +# ── 工作流 8: 合并单元格 ── + +# 1. 获取工作表列表 +dws sheet list --node --format json + +# 2. 合并所有单元格(默认 mergeAll) +dws sheet merge-cells --node --sheet-id --range "A1:B3" --format json + +# 3. 按行合并 +dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeRows --format json + +# 4. 按列合并 +dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeColumns --format json +``` + +```bash +# ── 工作流 9: 上传附件到表格 ── + +# 1. 基本用法: 上传本地文件到表格 +# dws sheet media-upload 在开源 v1.0.30 未暴露;附件上传请走 doc upload 或 drive upload-info + +# 2. 自定义附件显示名称 (--name 指定上传后在表格中显示的名称) +# 同上,sheet media-upload 在开源 v1.0.30 未暴露 + +# 3. 指定 MIME 类型 (文件扩展名无法推断时) +dws sheet media-upload --node --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream -f json + +# 4. 完整流程: 创建表格 → 上传附件 +dws sheet create --name "项目资料" -f json +# 提取 nodeId 后: +dws sheet media-upload --node --file ./design.pdf -f json +dws sheet media-upload --node --file ./timeline.xlsx --name "项目时间线.xlsx" -f json + +# ── 工作流 10: 写入图片到表格单元格 ── + +# 1. 基本用法: 写入图片到指定单元格 +dws sheet write-image --node --sheet-id --range A1:A1 --file ./chart.png -f json + +# 2. 指定显示尺寸 +dws sheet write-image --node --sheet-id --range B2:B2 --file ./logo.png --width 200 --height 100 -f json + +# 3. 自定义图片名称 +dws sheet write-image --node --sheet-id --range C3:C3 --file ./photo.jpg --name "产品图.jpg" -f json + +# 4. 完整流程: 创建表格 → 写表头 → 写入图片 +dws sheet create --name "产品目录" -f json +# 提取 nodeId 后: +dws sheet range update --node --sheet-id Sheet1 --range "A1:B1" --values '[["产品名称","产品图片"]]' -f json +dws sheet range update --node --sheet-id Sheet1 --range "A2:A2" --values '[["MacBook Pro"]]' -f json +dws sheet write-image --node --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json +``` + +```bash +# ── 工作流 11: 筛选视图管理 ── + +# 1. 获取工作表列表 +dws sheet list --node -f json + +# 2. 查看已有筛选视图 +dws sheet filter-view list --node --sheet-id -f json + +# 3. 创建筛选视图(不带条件) +dws sheet filter-view create --node --sheet-id \ + --name "我的筛选" --range "A1:E100" -f json + +# 4. 为筛选视图设置列条件(按值筛选) +dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' -f json + +# 5. 为筛选视图设置列条件(按条件筛选) +dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ + --column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' -f json + +# 6. 更新筛选视图名称和范围 +dws sheet filter-view update --node --sheet-id --filter-view-id \ + --name "销售数据筛选" --range "A1:F200" -f json + +# 7. 清除某列的筛选条件 +dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id \ + --column 0 -f json + +# 8. 删除筛选视图 +dws sheet filter-view delete --node --sheet-id --filter-view-id -f json +``` + +```bash +# ── 工作流 11b: 创建带条件的筛选视图(一步完成) ── + +# 创建筛选视图时直接指定筛选条件 +dws sheet filter-view create --node --sheet-id \ + --name "高销售额视图" --range "A1:E100" \ + --criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50000"}]}]' \ + -f json +``` + +```bash +# ── 工作流 12: 导出表格为 xlsx(单命令一站式)── + +# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) +# dws sheet export 在开源 v1.0.30 未暴露;请用钉钉客户端导出 +# 传入 URL 也可: +# 钉钉客户端导出请用 alidocs URL 直接打开后菜单导出 + +# 场景 B:导出并自动下载为本地文件 +# dws sheet export 在开源 v1.0.30 未暴露 + +# 场景 C:下载到目录,自动按链接推断文件名 +# dws sheet export 在开源 v1.0.30 未暴露 + +# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 +# 在线表格导出请在钉钉客户端进行 +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `create` | `nodeId` | list / info / new / range read / range update / find 的 --node | +| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | +| `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id | +| `info` | `rowCount` / `lastNonEmptyRow` | 确定数据范围、追加写入起始行 | +| `find` | `matchedCells` 中的 `a1Notation` | 定位目标单元格,用于 range read / range update | +| `append` | `a1Notation` 追加数据所在范围 | 确认追加位置 | +| `insert-dimension` | `a1Notation` 新插入区域范围 | 确认插入位置和范围 | +| `delete-dimension` | `a1Notation` 被删除区域范围 | 确认删除位置和范围 | +| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 | +| `merge-cells` | `a1Notation` 实际被合并的范围、`mergeType` 生效的合并方式 | 确认合并结果 | +| `media-upload` | `resourceId`、`resourceUrl` | 附件已上传到表格;`resourceUrl` 可用于 `create-float-image` 的 `--src` | +| `write-image` | `resourceId` | 图片已写入指定单元格 | +| `create-float-image` | `floatImage`(含 `id`、`src`、`range`、`width`、`height`、`offsetX`、`offsetY`) | `id` 用于后续 get / update / delete 的 `--float-image-id` | +| `get-float-image` | `floatImage`(完整信息) | 查看单个浮动图片详情 | +| `list-float-images` | `floatImages` 数组、`totalCount` | 获取所有浮动图片的 `id`,用于后续操作 | +| `update-float-image` | `floatImage`(更新后的完整信息) | 确认更新结果 | +| `delete-float-image` | `message` | 确认删除完成 | +| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 | +| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 | +| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | +| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | +| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | +| `filter-view list` | `filterViews` 筛选视图列表(含 `id`、`name`、`range`) | 获取 filterViewId 用于 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria | +| `filter-view info` | `id`、`name`、`range`、`criteria` | 查看单个视图完整配置,确认条件是否生效 | +| `filter-view create` | `id` 筛选视图 ID、`name`、`range` | 用于后续 update / delete / update-criteria / delete-criteria 的 --filter-view-id | +| `filter-view update` | `id`、`name`、`range`、`criteria` | 确认更新结果 | +| `filter-view delete` | `id` 被删除的筛选视图 ID | 确认删除完成 | +| `filter-view update-criteria` | `id` 筛选视图 ID | 确认条件设置完成 | +| `filter-view delete-criteria` | `id` 筛选视图 ID | 确认条件清除完成 | +| `filter-view list-criteria` | 所有列条件(按列偏移量为 key 的对象) | 了解当前视图已设置哪些列的条件 | +| `filter-view get-criteria` | 指定列的条件详情(`filterType`、`conditions` 等) | 查看某列的具体筛选规则 | +| `export` | `downloadUrl`(未指定 --output)/ `导出完成: `(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | + +## nodeId 多格式说明 + +所有 `--node` 参数同时支持文档 ID、文档 URL、表格分享链接,系统自动识别。详细格式和提取规则请参见前文「URL 识别与 NODE_ID 提取」章节。 + +> ** 禁止使用 `dentryId`**:`drive list` 返回结果中同时包含 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式,如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)两个字段。sheet 的 `--node` 和 `--folder` 参数**只能使用 `fileId`(UUID 格式)**,不能使用纯数字的 `dentryId`,传入纯数字会导致命令失败。 + +## values 参数格式说明 + +`--values` 为二维 JSON 数组,第一维为行,第二维为列: +- 字符串值: `"文本"` +- 数字值: `100` 或 `3.14` +- 公式: `"=SUM(B2:B4)"`(以 `=` 开头的字符串自动识别为公式) +- 清空单元格: 统一使用空字符串 `""`(不要用 `null` 取代,null 不会保留原值且全 null 会被视为无效调用跳过) + +维度必须与 `--range` 范围一致,例如 `--range "A1:B3"` 需要 3 行 2 列的数组。 + +## hyperlinks 参数格式说明 + +`--hyperlinks` 为二维 JSON 数组,每个元素为对象或 null: +- `type`: 链接类型,可选 `path`(外部链接)、`sheet`(工作表跳转)、`range`(单元格跳转) +- `link`: 链接地址 +- `text`: 显示文本 + +与 `--values` 共存时,hyperlinks 优先级更高。 + +## number-format 常用值 + +| 格式代码 | 说明 | 示例 | +|----------|------|------| +| `General` | 常规 | 1234.5 | +| `@` | 文本 | 001234 | +| `#,##0` | 整数千分位 | 1,235 | +| `#,##0.00` | 两位小数 | 1,234.50 | +| `0%` | 百分比 | 85% | +| `yyyy/m/d` | 日期 | 2026/3/15 | +| `hh:mm:ss` | 时间 | 14:30:00 | +| `¥#,##0` | 人民币 | ¥1,235 | + +## 注意事项(完整版) + +> 标 ★ 的条目已在前文「关键注意事项」中列出,此处为完整说明。 + +- ★ `--sheet-id` 获取规范(强制):所有涉及 `--sheet-id` 参数的命令(`info` / `new` / `range read` / `range update` / `find` / `append` / `insert-dimension` / `delete-dimension` / `update-dimension` / `move-dimension` / `add-dimension` / `merge-cells` / `unmerge-cells` / `replace` / `write-image` / `set-dropdown` / `get-dropdown` / `delete-dropdown` / `filter-view *` 等),除非用户主动提供了工作表 ID 或工作表名称,否则在 `sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 +- ★ `range update` 维度校验(强制):调用 `range update` 写入 `--values` 或 `--hyperlinks` 时,必须严格校验二维 JSON 数组的行数与列数与 `--range` 指定的范围完全一致: + - 例如 `--range "A1:C3"` 表示 3 行 × 3 列,`--values` 必须是 `[[v1,v2,v3],[v4,v5,v6],[v7,v8,v9]]` 这样 3×3 的数组 + - `--range "A1"` 表示 1 行 × 1 列,`--values` 必须是 `[[v]]` + - 行数不足需要用空字符串补齐,列数不足需要补齐到每行相同长度;禁止出现各行列数不一致或与 `--range` 不匹配的情况,否则调用会直接报错 + - 同时传入 `--values` 和 `--hyperlinks` 时,两个二维数组的行列数都必须与 `--range` 严格一致 +- ★ `range update` 清空单元格规范(强制):如需清空单元格内容,统一使用空字符串 `""`。禁止使用 `null`:`null` 不会保留单元格原值,也不存在"选择性保留"场景;且若 `--values` 全部为 `null`,整体调用会被视为无效而跳过,无任何写入效果 +- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录 +- `list` 返回所有工作表的 ID 和名称,是后续操作的必要前置步骤 +- `info` 不传 `--sheet-id` 时默认返回第一个工作表的详情 +- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 +- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) +- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表 +- `range update` 的 `--values` 和 `--hyperlinks` 至少传入一项 +- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(底层硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,底层超 30000 会直接报错 +- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式 +- `new` 创建工作表时,如名称与已有工作表重复,系统会自动重命名 +- `find` 返回匹配单元格的地址(A1 表示法)和值,无匹配时返回空数组 +- `find` 的 `--match-entire-cell` 用于精确匹配:只返回单元格内容完全等于搜索文本的结果,不会匹配包含该文本的单元格(例如搜索"苹果"时,只匹配"苹果",不匹配"苹果手机""苹果汁"等)。用户说"精确搜索/完全匹配/只搜等于XX的"时必须使用此参数 +- `find` 的 `--match-case` 默认为 true(区分大小写),设为 false 可忽略大小写 +- `find` 的 `--use-regexp` 启用后,`--find` 参数作为正则表达式处理 +- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确 +- `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号 +- `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致 +- `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update` +- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append` +- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` +- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` +- `insert-dimension` 的 `--length` 最大为 5000 +- `delete-dimension` 从指定位置起删除若干连续的行或列,删除后后续行/列向前移动填补空位 +- `delete-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` +- `delete-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` +- `delete-dimension` 的 `--length` 最大为 5000 +- `delete-dimension` 若需仅清空内容但保留行/列占位,请使用 `range update` 将目标区域写入空字符串 `""`(参见《range update 清空单元格规范》) +- `update-dimension` 批量更新连续行/列的显隐状态与行高/列宽 +- `update-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` +- `update-dimension` 的 `--start-index` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` +- `update-dimension` 的 `--length` 最大为 5000 +- `update-dimension` 的 `--hidden` 与 `--pixel-size` 至少必须提供一个 +- `update-dimension` 的 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽 +- `update-dimension` 当同时提供 `--hidden` 与 `--pixel-size` 时,将先应用尺寸再应用显隐,任一失败整体失败 +- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 +- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) +- `merge-cells` 的 `--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时忽略 `--sheet-id` +- `merge-cells` 如果目标区域与其他合并单元格、锁定区域或表格区域存在交集,合并将失败 +- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 +- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 +- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败 +- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 +- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL +- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) +- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 +- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 +- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 +- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) +- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 +- `replace` 的 `--find` 不能为空字符串,`--replace` 可以为空字符串(表示删除匹配内容) +- `replace` 的 `--match-case` 默认为 false(不区分大小写),与 `find` 的默认行为不同 +- ★ `replace` vs `range update`:需要批量替换文本时,必须使用 `replace` 命令,禁止用 `range update` 手动重写单元格来实现替换效果。`replace` 支持服务端全局替换,效率更高且会返回替换计数 +- ★ `move-dimension` vs `range update`:需要移动行或列时,必须使用 `move-dimension` 命令,禁止用 `range update` 读取数据后手动重写来模拟移动效果。`move-dimension` 是原子操作,能保留单元格的格式、合并状态等属性 +- `move-dimension` 的索引均为 0-based(第 1 行/列的索引为 0),`endIndex` 包含在移动范围内 +- `move-dimension` 的 `--destination-index` 不能在 [start-index, end-index] 范围内 +- `move-dimension` 的移动跨度(end-index - start-index + 1)不超过 5000 +- `move-dimension` 的 `--destination-index` 是目标位置的 0-based 索引,即移动到第 n 行/列则传 `n - 1`(通用公式:`destination-index = 目标行号(1-based) - 1`) +- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 +- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 +- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 +- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围 +- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 +- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false +- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 +- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id` +- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤 +- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}` +- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示 +- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置 +- `filter-view update` 的 `--name`、`--range`、`--criteria` 至少需要传入一个,未指定的字段保持不变 +- `filter-view update` 的 `--criteria` 中指定列的条件会被替换,未指定的列保持不变 +- `filter-view delete` 删除后该视图及其所有筛选条件将被永久移除,不可恢复 +- `filter-view delete` 不影响全局筛选或其他筛选视图 +- `filter-view update-criteria` 的 `--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列 +- `filter-view update-criteria` 设置条件后立即在该筛选视图中生效,仅影响当前视图,不影响全局筛选或其他筛选视图 +- `filter-view update-criteria` 的 `--filter-criteria` 中 `conditions` 最多 2 个条件,多条件之间通过 `conditionOperator` 指定逻辑关系(`and` 或 `or`) +- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete` +- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错 +- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作 +- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 +- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 +- 在线表格导出在开源 v1.0.30 暂不支持;请引导用户到钉钉客户端导出 +- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 +- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 +- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- `update` 的 `--title`、`--index`、`--hidden`、`--frozen-row-count`、`--frozen-column-count` 至少必须提供一个 +- `update` 的 `--title` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符 +- `update` 的 `--index` 为 0-based 非负整数,0 表示移动到最前面 +- `update` 的 `--hidden` 设为 true 时,至少需要保留一个可见的工作表,不能将所有工作表都隐藏 +- `update` 的 `--frozen-row-count` / `--frozen-column-count` 为非负整数,不能超过工作表的总行数/列数,设为 0 表示取消冻结 +- `update` 当同时提供多个属性时,所有属性将在同一次请求中更新 +- `copy` 复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表 +- `copy` 的 `--title` 可选,不传时系统自动生成名称(通常为"源名称 副本"或类似格式) +- `copy` 的 `--title` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符 +- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值 +- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置 +- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读) +- sheet 产品线仅支持 `axls`(在线电子表格,`contentType=ALIDOC`),不支持 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 +- 遇到未知 `alidocs` URL 时,必须先 probe(`dws doc info --node --format json`)确认 `contentType` 和 `extension`,才能决定是否走 sheet +- 当节点 `extension=xlsx` / `xls` / `xlsm` / `csv`(`contentType≠ALIDOC`)时,必须用 `dws doc download --node --output <路径>` 先下载到本地再处理,禁止调用任何 sheet 子命令(sheet 底层 MCP 工具只识别 axls,调用 xlsx 节点必失败) +- 要把在线表格导出为 xlsx 文件——开源 v1.0.30 暂不支持,请在钉钉客户端导出;要读已有的 xlsx 文件——走 `dws doc download` 后在本地解析 diff --git a/skills/multi/dingtalk-sheet/references/url-patterns.md b/skills/multi/dingtalk-sheet/references/url-patterns.md new file mode 100644 index 00000000..c65276e1 --- /dev/null +++ b/skills/multi/dingtalk-sheet/references/url-patterns.md @@ -0,0 +1,127 @@ +# URL 格式与处理规范 + +## alidocs URL 分流决策(必须首先执行) + +收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**: + +1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行 +2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行 +3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数 +4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可) +5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式 + +--- + +## 已知 URL 格式 + +需要自行拼接链接时,只能使用以下模板: + +| 产品 | 用途 | URL 格式 | ID 来源 | +|------|------|----------|---------| +| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` | +| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` | +| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` | +| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` | +| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` | +| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` | +| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` | + +不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。 + +## 分享短链处理 + +`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。 + +### 识别规则 + +URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如: +- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2` +- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7` +- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234` +- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789` + +> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。 + +### 处理方式 + +**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。 + +- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接 +- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入 + +``` +# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url) +read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2") +read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7") + +# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL) +dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json +dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json +``` + +### 当 `read_url` 返回内容不完整时 + +钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户: + +> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。 +> +> 你可以: +> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我 +> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取 + +--- + +## alidocs URL 类型探测流程 + +`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。 + +### 探测步骤 + +``` +Step 1 → dws doc info --node "" --format json +Step 2 → 从返回中提取 contentType、extension、nodeType 字段 +Step 3 → 按下方路由规则映射到对应产品 +``` + +### 路由映射表 + +| 条件 | 路由到产品 | 后续操作 | +|------|-----------|---------| +| `contentType=ALIDOC`, `extension=adoc` | `doc` | 切到 `dingtalk-doc` skill,按其中 `references/doc.md` 操作 | +| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./sheet.md) 操作(仅 `axls` 在线电子表格) | +| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,切到 `dingtalk-aitable` skill 操作 | +| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | +| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 | +| `nodeType=folder` | `doc` | 调用 `dws doc list --folder ` 列出指定文件夹直接子节点列表 | +| 以上均不匹配 | — | 告知用户当前暂不支持该类型 | + +> axls vs xlsx 关键区分: +> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) +> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) +> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(envelope schema 里有 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx + +### 示例 + +```bash +# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123 +dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json + +# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet +dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json + +# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet) +dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" + +# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载 +dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456" + +# 返回 nodeType=folder → 文件夹,列出子节点 +dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json +``` + +### 何时可跳过探测 + +当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**: +- 用户只粘贴 URL,无其他上下文 +- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格) +- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型 diff --git a/skills/multi/dingtalk-skill/SKILL.md b/skills/multi/dingtalk-skill/SKILL.md new file mode 100644 index 00000000..5783988e --- /dev/null +++ b/skills/multi/dingtalk-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: dingtalk-skill +description: 悟空技能管理(搜索 / 安装技能)。Use when 用户说 搜索技能/找技能/安装技能/技能市场/企业技能库。注意:这是元 skill —— 它管理的是 dws 平台上的其他 skill。命令前缀:dws skill。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 悟空技能管理 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[skill.md](references/skill.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "搜索技能 / 找技能" | `dws skill search --query "<关键词>" [--scopes "DingtalkMarket OrgInternal"]` | +| "下载技能包到本地临时目录" | `dws skill get --skill-id ` | +| "安装技能到 Agent 目录" | `dws skill install `(target: claude / cursor / codex / opencode / qoder / .) | + +## 环境 + +- `DWS_SKILL_API_HOST` 覆盖技能 API 地址(默认 `https://aihub.dingtalk.com`) + +## 兼容提示 + +- `dws skill find` → 用 `dws skill search --query <关键词>` diff --git a/skills/multi/dingtalk-skill/references/skill.md b/skills/multi/dingtalk-skill/references/skill.md new file mode 100644 index 00000000..d9a416c3 --- /dev/null +++ b/skills/multi/dingtalk-skill/references/skill.md @@ -0,0 +1,110 @@ +# 单命令产品合集 + +以下产品命令较少,合并参考。 + +--- + +## devdoc — 开放平台文档 + +### 搜索开放平台文档 +``` +Usage: + dws devdoc article search [flags] +Example: + dws devdoc article search --query "OAuth2 接入" --page 1 --size 10 --format json +Flags: + --query string 搜索关键词 (必填) + --page string 页码 (默认 1) + --size string 每页数量 (默认 10) +``` + +## live — 直播 + +### 查看我的直播列表 +``` +Usage: + dws live stream list [flags] +Example: + dws live stream list --format json +``` + +--- + +## skill — 技能管理 + +悟空技能市场与企业技能库:搜索技能、安装到本地 Agent 目录、从本地目录或 zip 发布到企业技能库。 + +### 搜索技能 + +``` +Usage: + dws skill search [flags] +Example: + dws skill search --query "周报" + dws skill search --query "日报" --scopes "OrgInternal" + dws skill search --query "日报" --scopes "DingtalkMarket OrgInternal" +Flags: + --query string 搜索关键词 (必填) + --scopes string 查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能 +``` + +返回字段: +- `skillId` — 技能 ID(后续 `install` 需要) +- `name` — 技能唯一标识(SKILL.md 的 name) +- `displayName` — 人类可读名称 +- `displayDescription` — 人类可读描述 +- `version` — 最新版本号 +- `relevanceScore` — 向量相关性分数 +- `source` — 来源:`DingtalkMarket`(钉钉市场)/ `OrgInternal`(企业内部) +- `securityStatus` — 安全检测状态:`passed`(通过)/ `failed`(未通过)/ `checking`(检测中) + +安全提示: 安全检测未通过的技能会标注 ⚠️ 警告,不建议安装。 + +前置: 已登录钉钉(未登录会由系统自动触发授权;可用 `dws auth status` 确认)(调用技能市场接口需 access token)。 + +兼容提示: `dws skill find` 会提示改用 `dws skill search --query <关键词>`。 + +### 安装技能 + +``` +Usage: + dws skill install [flags] +Example: + dws skill install skill-123 claude # 安装到 ~/.claude/skills/ + dws skill install skill-123 cursor # 安装到 ~/.cursor/skills/ + dws skill install skill-123 codex # 安装到 ~/.codex/skills/ + dws skill install skill-123 . # 安装到当前目录 +Args: + 技能 ID(必填,从 search 结果获取) + 目标 Agent:claude / cursor / codex / qoder / opencode 或 . 表示当前目录 +``` + +流程: 下载技能包 → 解压 → 调用 real-cli 注册到悟空 SkillStore。 + +安全拦截: 安全检测未通过的技能默认拒绝安装,使用 `--force` 可强制安装。 + +前置: 已登录钉钉(未登录会由系统自动触发授权;可用 `dws auth status` 确认);悟空 App 已安装。 + +兼容提示: `dws skill add` 会提示改用 `dws skill install `(位置参数)。 + +环境: 技能 API 默认 `https://mcp.dingtalk.com`;可通过 `DWS_SKILL_API_HOST` 覆盖。 + +## 意图判断 + +- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search` +- 用户说"直播/我的直播" → `live stream list` +- 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接) + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `devdoc article search` | 文档链接 | 直接展示给用户 | +| `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install ` | +| `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 | +| `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 | + +## 相关产品 + +- `dingtalk-calendar` (`references/calendar.md`) — 日历日程管理(含参与者/会议室) +- `dingtalk-conference` (`references/conference.md`) — 视频会议(发起/预约/邀请入会/会中控制) diff --git a/skills/multi/dingtalk-todo/SKILL.md b/skills/multi/dingtalk-todo/SKILL.md new file mode 100644 index 00000000..2685eaa5 --- /dev/null +++ b/skills/multi/dingtalk-todo/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dingtalk-todo +description: 钉钉待办 / TODO。Use when 用户说 创建待办/TODO/任务提醒/指派任务/标记完成/查待办/紧急待办/循环待办/批量建待办/逾期待办。Distinct from dingtalk-report(日报周报)、dingtalk-oa(审批)、dingtalk-calendar(日程)。命令前缀:dws todo。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉待办 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors ` | +| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) | +| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` | +| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:\nRRULE:FREQ=DAILY;INTERVAL=1"` | +| "批量建待办(JSON 文件)" | `python scripts/todo_batch_create.py todos.json` | +| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` | +| "逾期待办" | `python scripts/todo_overdue_check.py` | +| "标记完成 / 重开" | `dws todo task done --task-id --status true\|false` | +| "修改标题/截止时间/优先级" | `dws todo task update --task-id ...` | +| "删除待办" | `dws todo task delete --task-id `(需用户确认) | + +## 参数硬约束 + +- 任务详情只用 `dws todo task get --task-id `;不要写 `task detail`。 +- 完成状态首选 `dws todo task done --task-id --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。 +- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。 +- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。 +- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。 +- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。 +- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。 +- 所有 dws 命令带 `--format json`。 + +## 跨产品协作 + +- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId` +- 会后从听记自动建待办 → 切到 `dingtalk-minutes` +- 项目进度汇总写文档 → 切到 `dingtalk-doc` diff --git a/skills/multi/dingtalk-todo/references/02-task.md b/skills/multi/dingtalk-todo/references/02-task.md new file mode 100644 index 00000000..55b881bd --- /dev/null +++ b/skills/multi/dingtalk-todo/references/02-task.md @@ -0,0 +1,19 @@ +# 任务管理 + +> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`list-todo`、`get-todo-detail`、`update-todo-status`、`query-todo-by-topic`。其中 `list-todo` 统一覆盖 open/completed/all(`--status false|true` 或不传),`update-todo-status` 统一覆盖 complete/reopen(`--status true|false`)。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。 + +## Recipe 速查(非 SKILL lite) + +| Recipe | 步骤(命令均须 `--format json`,下略) | +|--------|----------------------------------------| +| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)
2. `todo task create --title "<标题>" --executors [,...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId` | +| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)
2. `todo task create --title "<标题>" --executors --due "<首次截止ISO>" --recurrence "DTSTART:\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId` | +| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`
2. `todo task update --task-id --due "<新截止时间>"` | + +## Full / 组合(固定路线) + +| Recipe | 行动指南(固定路线) | +|--------|---------------------| +| generate-progress-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行
2. 交叉比对各源数据
3. `doc create --name "<报告名>" --content "<报告内容>"` | +| batch-create-todo | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行 → 从结果提取任务条目
2. 每条:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
3. **优先**:将待办写入 `todos.json`(格式见 [todo_batch_create.py](../scripts/todo_batch_create.py)),执行 `python scripts/todo_batch_create.py todos.json`
备选:逐条 `todo task create --title "<标题>" --executors ` → 汇总回显
**单批超 30 条须用户确认** | +| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `todo task create --title "<标题>" --executors ` → 取 `todoTaskId`
3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group --text "<通知内容>"` 通知 | diff --git a/skills/multi/dingtalk-todo/references/todo.md b/skills/multi/dingtalk-todo/references/todo.md new file mode 100644 index 00000000..d61baaf4 --- /dev/null +++ b/skills/multi/dingtalk-todo/references/todo.md @@ -0,0 +1,354 @@ +# 待办 (todo) 命令参考 + +> ⚠️ **CLI 暴露状态(v1.0.30)**:当前可调用:`task list / task get / task create / task update / task done / task delete`。**尚未暴露**:`task add-executor / remove-executor / add-participant / remove-participant / create-sub / add-reminder / reset-reminder`,以及 `todo comment` 整个子命令组(add / delete / list),跑这些命令会 fall back 到 `dws todo` 帮助页面,请勿当作可用接口调用。文档保留是因为它们是已规划的能力。 + +## 命令总览 + +### 创建待办 +``` +Usage: + dws todo task create [flags] +Example: + dws todo task create --title "修复线上Bug" --executors , --priority 40 + dws todo task create --title "每日站会" --executors --due "2026-03-20T10:00:00+08:00" --recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1" +Flags: + --due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder) + --executors string 执行者 userId 列表 (必填) + --priority string 优先级: 10低/20普通/30较高/40紧急 + --recurrence string 循环待办 (需先设置 --due); 仅支持按天循环,格式见下方说明 + --title string 待办标题 (必填) +``` + +### 创建子待办 +``` +Usage: + dws todo task create-sub [flags] +Example: + dws todo task create-sub --parent-id --title "子任务标题" --executors , --priority 40 + dws todo task create-sub --parent-id --title "子任务标题" --executors --due "2026-03-20T10:00:00+08:00" +Flags: + --due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder) + --executors string 执行者 userId 列表 (必填) + --parent-id string 父待办任务 ID (必填,该信息可以通过创建待办接口或者查询待办列表接口返回) + --priority string 优先级: 10低/20普通/30较高/40紧急 + --recurrence string 循环待办 (需先设置 --due); 仅支持按天循环,格式见下方说明 + --title string 子待办标题 (必填) +``` + +### 查询待办列表 +``` +Usage: + dws todo task list [flags] +Example: + dws todo task list --page 1 --size 20 --status false +Flags: + --page string 页码 (默认 1) + --size string 每页数量 (默认 20) + --status string true=已完成, false=未完成 +``` + +### 修改待办任务 +``` +Usage: + dws todo task update [flags] +Example: + dws todo task update --task-id --title "新标题" + dws todo task update --task-id --priority 40 --due "2026-03-10T18:00:00+08:00" + dws todo task update --task-id --done true +Flags: + --done string 完成状态: true/false + --due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder) + --priority string 优先级: 10低/20普通/30较高/40紧急 + --task-id string 待办任务 ID (必填) + --title string 新标题 +``` + +### 修改执行者的待办完成状态 +``` +Usage: + dws todo task done [flags] +Example: + dws todo task done --task-id --status true + dws todo task done --task-id --status false +Flags: + --status string 完成状态: true=已完成, false=未完成 (必填) + --task-id string 待办任务 ID (必填) +``` + +### 待办详情 +``` +Usage: + dws todo task get [flags] +Example: + dws todo task get --task-id +Flags: + --task-id string 待办任务 ID (必填) +``` + +### 删除待办 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws todo task delete [flags] +Example: + dws todo task delete --task-id + dws todo task delete --task-id --yes +Flags: + --task-id string 待办任务 ID (必填) +``` + +### 新增待办评论 +``` +Usage: + dws todo comment add [flags] +Example: + dws todo comment add --task-id --content "评论内容" +Flags: + --task-id string 待办任务 ID (必填) + --content string 评论内容 (必填) +``` + +### 查询待办评论列表 +``` +Usage: + dws todo comment list [flags] +Example: + dws todo comment list --task-id + dws todo comment list --task-id --page 1 --size 20 +Flags: + --task-id string 待办任务 ID (必填) + --page string 页码 (默认 1) + --size string 每页数量 (默认 20) +``` + +### 删除待办评论 + +> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 + +``` +Usage: + dws todo comment delete [flags] +Example: + dws todo comment delete --task-id --comment-id + dws todo comment delete --task-id --comment-id --yes +Flags: + --task-id string 待办任务 ID (必填) + --comment-id string 评论 ID (必填) + --yes 跳过二次确认 (慎用) +``` + +### 添加待办执行人 +``` +Usage: + dws todo task add-executor [flags] +Example: + dws todo task add-executor --task-id --executors , +Flags: + --executors string 执行者 userId 列表 (必填) + --task-id string 待办任务 ID (必填) +``` +### 移除待办执行人 +``` +Usage: + dws todo task remove-executor [flags] +Example: + dws todo task remove-executor --task-id --executors , +Flags: + --executors string 执行者 userId 列表 (必填) + --task-id string 待办任务 ID (必填) +``` +### 添加待办参与人 +``` +Usage: + dws todo task add-participant [flags] +Example: + dws todo task add-participant --task-id --participants , +Flags: + --participants string 参与人 userId 列表 (必填) + --task-id string 待办任务 ID (必填) +``` +### 移除待办参与人 +``` +Usage: + dws todo task remove-participant [flags] +Example: + dws todo task remove-participant --task-id --participants , +Flags: + --participants string 参与人 userId 列表 (必填) + --task-id string 待办任务 ID (必填) +``` + +### 添加待办提醒 +``` +Usage: + dws todo task add-reminder [flags] +Example: + dws todo task add-reminder --task-id --base-time dueTime --due-date-offset -30 + dws todo task add-reminder --task-id --base-time customTime --reminder-time-stamp "2026-03-10T18:00:00+08:00" +Flags: + --base-time string 提醒基准时间: dueTime/customTime (必填) + --due-date-offset string 截止时间偏移量 (baseTime=dueTime 时必填) + --reminder-time-stamp string 自定义提醒时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;baseTime=customTime 时必填) + --task-id string 待办任务 ID (必填) +``` + +参数说明: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `--base-time` | string | 提醒基准时间,必填。`dueTime` = 基于截止时间偏移;`customTime` = 自定义时间戳 | +| `--due-date-offset` | number | 截止时间偏移量(分钟),`baseTime=dueTime` 时必填。负数表示提前,如 `-30` 表示截止前 30 分钟 | +| `--reminder-time-stamp` | string | 自定义提醒时间,ISO-8601 格式(如 `2026-03-10T18:00:00+08:00`),`baseTime=customTime` 时必填 | + +### 重置待办提醒 +``` +Usage: + dws todo task reset-reminder [flags] +Example: + dws todo task reset-reminder --task-id + dws todo task reset-reminder --task-id --reminder-rules '[{"dueDateOffset":-30,"baseTime":"dueTime"},{"reminderTimeStamp":"2026-03-10T18:00:00+08:00","baseTime":"customTime"}]' +Flags: + --reminder-rules string 提醒规则 JSON 数组 (可选,为空则清除提醒) + --task-id string 待办任务 ID (必填) +``` + +`--reminder-rules` 数据结构说明: + +JSON 数组,每个元素为一条提醒规则,支持两种 `baseTime` 模式混合使用: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `baseTime` | string | 提醒基准时间,必填。`dueTime` = 基于截止时间偏移;`customTime` = 自定义时间戳 | +| `dueDateOffset` | number | 截止时间偏移量(分钟),`baseTime=dueTime` 时必填。负数表示提前,如 `-30` 表示截止前 30 分钟 | +| `reminderTimeStamp` | string | 自定义提醒时间,ISO-8601 格式(如 `2026-03-10T18:00:00+08:00`),`baseTime=customTime` 时必填 | + +示例: +```json +[ + {"dueDateOffset": -30, "baseTime": "dueTime"}, + {"reminderTimeStamp": "2026-03-10T18:00:00+08:00", "baseTime": "customTime"} +] +``` +以上表示两条提醒规则:第一条在截止时间前 30 分钟提醒,第二条在指定时间(ISO-8601)提醒。 + +## 意图判断 + +用户说"加个待办/记一下/TODO" → `task create` +用户说"每天重复/循环待办/按天重复" → `task create`(需 `--due` + `--recurrence`) +用户说"加个子任务/创建子待办" → `task create-sub` +用户说"看看待办/我有啥要做" → `task list` +用户说"改个待办/修改待办标题/改优先级" → `task update` +用户说"做完了/完成待办/标记完成" → `task done` +用户说"看看待办详情" → `task get` +用户说"删除待办/取消待办" → `task delete` +用户说"给待办加条评论/留个备注" → `comment add` +用户说"看看这个待办的评论" → `comment list` +用户说"删除这条评论" → `comment delete` +用户说"加个执行人/添加执行者" → `task add-executor` +用户说"移除执行人/删除执行者" → `task remove-executor` +用户说"加个参与人/添加参与者" → `task add-participant` +用户说"移除参与人/删除参与者" → `task remove-participant` +用户说"给待办加个提醒/设置提醒" → `task add-reminder` +用户说"重置提醒/清除提醒/修改提醒规则" → `task reset-reminder` + +关键区分: todo(个人待办) + +## 核心工作流 + +```bash +# 1. 创建待办 — 提取 todoTaskId +dws todo task create --title "修复线上Bug" --executors userId1,userId2 \ + --priority 40 --due "2026-03-10T18:00:00+08:00" --format json + +# 1b. 创建按天循环的待办(必须先有 --due;recurrence 与 MCP create_personal_todo 一致) +dws todo task create --title "每日站会" --executors userId1 \ + --due "2026-03-20T10:00:00+08:00" \ + --recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1" --format json + +# 1c. 创建子待办(需先获取父待办 ID) +dws todo task create-sub --parent-id --title "子任务标题" --executors userId1 \ + --priority 40 --due "2026-03-10T18:00:00+08:00" --format json + +# 2. 查看未完成待办 +dws todo task list --page 1 --size 20 --status false --format json + +# 3. 查看待办详情 +dws todo task get --task-id --format json + +# 4. 修改待办信息 +dws todo task update --task-id --title "新标题" --priority 40 --format json + +# 5. 标记待办完成 +dws todo task done --task-id --status true --format json + +# 6. 删除待办 +dws todo task delete --task-id --yes --format json + +# 7. 给待办新增评论 +dws todo comment add --task-id --content "已开始处理" --format json + +# 8. 查看待办评论列表 +dws todo comment list --task-id --page 1 --size 20 --format json + +# 9. 删除待办评论 +dws todo comment delete --task-id --comment-id --yes --format json + +# 10. 添加待办执行人 +dws todo task add-executor --task-id --executors userId1,userId2 --format json +# 11. 移除待办执行人 +dws todo task remove-executor --task-id --executors userId1 --format json +# 12. 添加待办参与人 +dws todo task add-participant --task-id --participants userId1,userId2 --format json +# 13. 移除待办参与人 +dws todo task remove-participant --task-id --participants userId1 --format json + +# 14. 添加待办提醒(基于截止时间偏移,待办必须有截止时间) +dws todo task add-reminder --task-id --base-time dueTime --due-date-offset --format json +# 15. 添加待办提醒(自定义时间戳) +dws todo task add-reminder --task-id --base-time customTime --reminder-time-stamp "2026-03-10T18:00:00+08:00" --format json +# 16. 重置待办提醒 +dws todo task reset-reminder --task-id --format json +# 17. 重置待办提醒(指定新规则) +dws todo task reset-reminder --task-id --reminder-rules '' --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|---------------------------------------------| +| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id | +| `task list` | `result[].id` | update/done/get/delete 的 --task-id | +| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id | +| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id | +| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 | +| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id | + +## 注意事项 + +- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急 +- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00) +- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒” +- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致) +- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule +- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数 +- todo 是个人待办管理产品 +- `task update` 可同时修改标题/优先级/截止时间/完成状态 +- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同 +- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认 +- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取 +- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId +- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId +- 执行人 (executor) 与参与人 (participant) 的区别:执行人负责完成待办,参与人仅关注待办进度 +- `task add-reminder` 用于为待办添加提醒,`--base-time` 支持 `dueTime`(基于截止时间偏移,待办必须有截止时间)和 `customTime`(自定义时间戳)两种模式 +- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 则清除所有提醒 + + +## 自动化脚本 + +| 脚本 | 场景 | 用法 | +|------|------|------| +| [todo_daily_summary.py](../scripts/todo_daily_summary.py) | 查看今天/明天/本周未完成待办汇总 | `python todo_daily_summary.py today` | +| [todo_batch_create.py](../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json` | +| [todo_overdue_check.py](../scripts/todo_overdue_check.py) | 扫描逾期待办输出逾期清单 | `python todo_overdue_check.py` | diff --git a/skills/multi/dingtalk-todo/scripts/todo_batch_create.py b/skills/multi/dingtalk-todo/scripts/todo_batch_create.py new file mode 100644 index 00000000..ae021fdd --- /dev/null +++ b/skills/multi/dingtalk-todo/scripts/todo_batch_create.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +从 JSON 文件批量创建待办(含优先级、截止时间、执行者) + +用法: + python todo_batch_create.py todos.json + python todo_batch_create.py todos.json --dry-run + +todos.json 格式: + [ + {"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40}, + {"title": "写周报", "executors": "userId1", "due": "2026-03-15"}, + {"title": "代码评审", "executors": "userId1"}, + {"title": "每日站会", "executors": "userId1", "due": "2026-03-20", + "recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"} + ] + +字段说明: +- title: 待办标题 (必填) +- executors: 执行者 userId,多人逗号分隔 (必填) +- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选) +- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选) +- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致 +""" + +import sys +import json +import subprocess +import re +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional + +ALLOWED_PRIORITIES = {10, 20, 30, 40} +DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$') +MAX_FILE_SIZE = 10 * 1024 * 1024 + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Dict[str, Any]]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return {'dry_run': True} + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f" ✗ 错误:{result.stderr.strip()}") + return None + return json.loads(result.stdout) + except subprocess.TimeoutExpired: + print(' ✗ 命令执行超时', file=sys.stderr) + return None + except (json.JSONDecodeError, FileNotFoundError) as e: + print(f" ✗ 错误:{e}", file=sys.stderr) + return None + + +def parse_due(due_value) -> Optional[str]: + if not due_value: + return None + due_str = str(due_value) + if due_str.isdigit() and len(due_str) >= 10: + return due_str + if DATE_PATTERN.match(due_str): + dt = datetime.strptime(due_str, '%Y-%m-%d') + dt = dt.replace(hour=23, minute=59, second=59) + return str(int(dt.timestamp() * 1000)) + print(f" ⚠ 无法解析截止时间:{due_value},跳过") + return None + + +def validate_todo(item: Dict[str, Any], idx: int) -> bool: + if not isinstance(item, dict): + print(f" ✗ #{idx+1} 不是有效对象") + return False + if not item.get('title', '').strip(): + print(f" ✗ #{idx+1} 缺少 title") + return False + if not item.get('executors', '').strip(): + print(f" ✗ #{idx+1} 缺少 executors") + return False + priority = item.get('priority') + if priority is not None and int(priority) not in ALLOWED_PRIORITIES: + print(f" ✗ #{idx+1} 无效优先级:{priority}") + return False + recurrence = item.get('recurrence') + if recurrence and not str(recurrence).strip(): + print(f" ✗ #{idx+1} recurrence 不能为空字符串") + return False + if recurrence and not item.get('due'): + print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due") + return False + return True + + +def main(): + dry_run = '--dry-run' in sys.argv + args = [a for a in sys.argv[1:] if a != '--dry-run'] + if not args: + print(__doc__) + sys.exit(1) + + file_path = Path(args[0]) + if not file_path.exists(): + print(f"错误:文件不存在:{file_path}") + sys.exit(1) + if file_path.stat().st_size > MAX_FILE_SIZE: + print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)") + sys.exit(1) + + with open(file_path, 'r', encoding='utf-8') as f: + todos = json.load(f) + if not isinstance(todos, list) or not todos: + print('错误:JSON 文件必须是非空数组') + sys.exit(1) + + for i, item in enumerate(todos): + if not validate_todo(item, i): + sys.exit(1) + + print(f"📋 准备创建 {len(todos)} 条待办\n") + success, fail = 0, 0 + for i, item in enumerate(todos): + title = item['title'].strip() + cmd_args = [ + 'todo', 'task', 'create', + '--title', title, + '--executors', item['executors'].strip(), + '--format', 'json', + ] + priority = item.get('priority') + if priority is not None: + cmd_args.extend(['--priority', str(int(priority))]) + due = parse_due(item.get('due')) + if due: + cmd_args.extend(['--due', due]) + recurrence = item.get('recurrence') + if recurrence: + rr = str(recurrence).replace('\\n', '\n') + cmd_args.extend(['--recurrence', rr]) + + result = run_dws(cmd_args, dry_run=dry_run) + if result: + print(f" ✓ [{i+1}/{len(todos)}] {title}") + success += 1 + else: + print(f" ✗ [{i+1}/{len(todos)}] {title}") + fail += 1 + + print(f"\n完成: 成功 {success}, 失败 {fail}") + sys.exit(0 if fail == 0 else 1) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-todo/scripts/todo_daily_summary.py b/skills/multi/dingtalk-todo/scripts/todo_daily_summary.py new file mode 100644 index 00000000..b8668e3f --- /dev/null +++ b/skills/multi/dingtalk-todo/scripts/todo_daily_summary.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +查询今天/明天/本周未完成的待办并汇总输出 + +用法: + python todo_daily_summary.py # 默认查今天 + python todo_daily_summary.py today # 今天的待办 + python todo_daily_summary.py tomorrow # 明天的待办 + python todo_daily_summary.py week # 本周的待办 + python todo_daily_summary.py --dry-run # 仅显示将执行的命令 +""" + +import sys +import json +import subprocess +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'} +PAGE_SIZE = 50 +MAX_PAGES = 10 + + +def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except subprocess.TimeoutExpired: + print('错误:命令执行超时', file=sys.stderr) + return None + except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def get_date_range(scope: str): + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + if scope == 'today': + return today_start, today_start + timedelta(days=1) + elif scope == 'tomorrow': + tmr = today_start + timedelta(days=1) + return tmr, tmr + timedelta(days=1) + elif scope == 'week': + week_start = today_start - timedelta(days=today_start.weekday()) + return week_start, week_start + timedelta(days=7) + return today_start, today_start + timedelta(days=1) + + +def fetch_all_todos( + dry_run: bool = False, +) -> List[Dict[str, Any]]: + all_todos: List[Dict[str, Any]] = [] + for page in range(1, MAX_PAGES + 1): + data = run_dws([ + 'todo', 'task', 'list', + '--page', str(page), + '--size', str(PAGE_SIZE), + '--status', 'false', + '--format', 'json', + ], dry_run=dry_run) + if dry_run: + return [] + if not data: + break + if isinstance(data, list): + items = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + items = inner.get('todoCards', []) + elif isinstance(inner, list): + items = inner + else: + items = [] + else: + items = [] + if not items or not isinstance(items, list): + break + all_todos.extend(items) + if len(items) < PAGE_SIZE: + break + return all_todos + + +def format_priority(p) -> str: + try: + return PRIORITY_MAP.get(int(p), str(p)) + except (ValueError, TypeError): + return '普通' + + +def format_due(due_ms) -> str: + if not due_ms: + return '无截止时间' + try: + dt = datetime.fromtimestamp(int(due_ms) / 1000) + return dt.strftime('%Y-%m-%d %H:%M') + except (ValueError, TypeError, OSError): + return str(due_ms) + + +def filter_by_due( + todos: List[Dict[str, Any]], start: datetime, end: datetime, +) -> List[Dict[str, Any]]: + start_ms = int(start.timestamp() * 1000) + end_ms = int(end.timestamp() * 1000) + result = [] + for t in todos: + due = t.get('dueTime') or t.get('due') + if not due: + result.append(t) + continue + try: + due_val = int(due) + if start_ms <= due_val < end_ms: + result.append(t) + except (ValueError, TypeError): + result.append(t) + return result + + +def print_summary( + todos: List[Dict[str, Any]], scope: str, + start: datetime, end: datetime, +): + scope_label = { + 'today': '今天', 'tomorrow': '明天', 'week': '本周', + }.get(scope, scope) + print(f"\n📋 {scope_label}未完成待办 " + f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})") + print('=' * 50) + if not todos: + print(' ✅ 暂无待办,轻松一下!') + return + urgent = [t for t in todos if format_priority( + t.get('priority')) == '紧急'] + if urgent: + print(f"\n🔴 紧急 ({len(urgent)} 条)") + for t in urgent: + title = t.get('subject') or t.get('title', '无标题') + print(f" • {title} ⏰ {format_due(t.get('dueTime'))}") + normal = [t for t in todos if t not in urgent] + if normal: + print(f"\n📌 其他 ({len(normal)} 条)") + for t in normal: + title = t.get('subject') or t.get('title', '无标题') + pri = format_priority(t.get('priority')) + print(f" • [{pri}] {title} ⏰ {format_due(t.get('dueTime'))}") + print(f"\n合计: {len(todos)} 条待办") + + +def main(): + dry_run = '--dry-run' in sys.argv + args = [a for a in sys.argv[1:] if a != '--dry-run'] + scope = args[0] if args else 'today' + if scope not in ('today', 'tomorrow', 'week'): + print(__doc__) + sys.exit(1) + start, end = get_date_range(scope) + todos = fetch_all_todos(dry_run=dry_run) + if dry_run: + return + filtered = filter_by_due(todos, start, end) + print_summary(filtered, scope, start, end) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-todo/scripts/todo_overdue_check.py b/skills/multi/dingtalk-todo/scripts/todo_overdue_check.py new file mode 100644 index 00000000..c65160f6 --- /dev/null +++ b/skills/multi/dingtalk-todo/scripts/todo_overdue_check.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +扫描已过截止时间但未完成的待办,输出逾期清单 + +用法: + python todo_overdue_check.py + python todo_overdue_check.py --dry-run +""" + +import sys +import json +import subprocess +from datetime import datetime +from typing import List, Dict, Any, Optional + +PAGE_SIZE = 50 +MAX_PAGES = 10 +PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'} + + +def run_dws( + args: List[str], dry_run: bool = False, +) -> Optional[Any]: + cmd = ['dws'] + args + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return None + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(f"错误:{result.stderr.strip()}", file=sys.stderr) + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, + FileNotFoundError) as e: + print(f"错误:{e}", file=sys.stderr) + return None + + +def fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]: + all_todos: List[Dict[str, Any]] = [] + for page in range(1, MAX_PAGES + 1): + data = run_dws([ + 'todo', 'task', 'list', + '--page', str(page), '--size', str(PAGE_SIZE), + '--status', 'false', '--format', 'json', + ], dry_run=dry_run) + if dry_run or not data: + break + if isinstance(data, list): + items = data + elif isinstance(data, dict): + inner = data.get('result', data) + if isinstance(inner, dict): + items = inner.get('todoCards', []) + elif isinstance(inner, list): + items = inner + else: + items = [] + else: + items = [] + if not items or not isinstance(items, list): + break + all_todos.extend(items) + if len(items) < PAGE_SIZE: + break + return all_todos + + +def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + now_ms = int(datetime.now().timestamp() * 1000) + overdue = [] + for t in todos: + due = t.get('dueTime') or t.get('due') + if not due: + continue + try: + if int(due) < now_ms: + overdue.append(t) + except (ValueError, TypeError): + continue + return overdue + + +def days_overdue(due_ms) -> int: + now = datetime.now() + try: + due_dt = datetime.fromtimestamp(int(due_ms) / 1000) + return max(0, (now - due_dt).days) + except (ValueError, TypeError, OSError): + return 0 + + +def main(): + dry_run = '--dry-run' in sys.argv + todos = fetch_all_undone(dry_run=dry_run) + if dry_run: + return + + overdue = find_overdue(todos) + overdue.sort( + key=lambda t: int(t.get('dueTime') or t.get('due', 0)) + ) + + print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})") + print('=' * 50) + + if not overdue: + print(' ✅ 没有逾期待办,继续保持!') + return + + for t in overdue: + title = t.get('subject') or t.get('title', '无标题') + due = t.get('dueTime') or t.get('due') + days = days_overdue(due) + pri = PRIORITY_MAP.get( + int(t.get('priority', 20)), '普通' + ) + due_str = datetime.fromtimestamp( + int(due) / 1000 + ).strftime('%Y-%m-%d') + print(f" 🔴 [{pri}] {title}") + print(f" 截止: {due_str} 逾期: {days} 天") + + print(f"\n合计: {len(overdue)} 条逾期待办") + sys.exit(1 if overdue else 0) + + +if __name__ == '__main__': + main() diff --git a/skills/multi/dingtalk-wiki/SKILL.md b/skills/multi/dingtalk-wiki/SKILL.md new file mode 100644 index 00000000..eb8093cf --- /dev/null +++ b/skills/multi/dingtalk-wiki/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dingtalk-wiki +description: 钉钉知识库(Wiki 空间)。Use when 用户说 知识库/wiki/创建知识库/搜索知识库空间/我的文档/知识库归档。Distinct from dingtalk-doc(单文档编辑)、dingtalk-drive(钉盘文件)。命令前缀:dws wiki。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉知识库 Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + + +> 命令参考:[wiki.md](references/wiki.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "创建知识库" | `dws wiki space create --name "<名称>" [--description "<描述>"]` | +| "搜索知识库空间" | `dws wiki space search --keyword "<关键词>" [--limit <1-20>]` | +| "我的文档 / 个人知识库" | `dws wiki space list --type myWikiSpace` | +| "列出组织知识库" | `dws wiki space list [--type orgWikiSpace] [--limit <1-50>]` | + +## 评测高频硬约束 + +- `space search` 用 `--keyword`,不要用 `--query`;`search` 没有 `--type` flag,按类型筛选请走 `space list --type myWikiSpace/orgWikiSpace`。 +- 用户说"我的文档/个人空间/my workspace"时必须用 `dws wiki space list --type myWikiSpace --format json`;不要往 `search` 里拼 `--type`。 +- 用户给空关键词时,不要构造空 `--keyword ""`;若语义是我的文档则走 `space list --type myWikiSpace`,否则请用户补关键词。 +- 搜到空间后复用返回的 `workspaceId/id`,知识库内具体文档的创建、搜索、读写切到 `dingtalk-doc`,不要在 `wiki` 下编造 doc 子命令。 +- 所有 `dws wiki` 命令加 `--format json`。 + +## 跨产品协作 + +- 知识库内具体文档读写 → 切到 `dingtalk-doc` +- 文件存储 → 切到 `dingtalk-drive` diff --git a/skills/multi/dingtalk-wiki/references/wiki.md b/skills/multi/dingtalk-wiki/references/wiki.md new file mode 100644 index 00000000..19d0d4a5 --- /dev/null +++ b/skills/multi/dingtalk-wiki/references/wiki.md @@ -0,0 +1,199 @@ +# 知识库 (wiki) 命令参考 + +## 查询命令帮助 + +当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 + +```bash +# 查看 wiki 下所有子命令 +dws wiki --help + +# 查看具体命令的完整参数说明 +dws wiki space get --help +dws wiki member add --help + +# 查看子命令组下的所有命令 +dws wiki space --help +dws wiki member --help +``` + +规则: +- 参数名不确定时 → 先 `--help`,再调用 +- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 +- 不确定某个功能是否存在时 → `dws wiki --help` 查看命令列表 + +## 命令总览 + +### 创建知识库 +``` +Usage: + dws wiki space create [flags] +Example: + dws wiki space create --name "产品文档库" --format json + dws wiki space create --name "技术方案" --description "团队技术方案归档" --format json +Flags: + --name string 知识库名称 (必填,不超过 100 字符) + --description string 知识库描述 (选填,不超过 500 字符) + --icon string 知识库图标标识 (选填) +``` + +### 查看知识库详情 +``` +Usage: + dws wiki space get [flags] +Example: + dws wiki space get --id --format json + dws wiki space get --id "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --format json +Flags: + --workspace string 知识库 ID 或 URL (必填) +``` + +支持传入知识库 ID 或知识库 URL,系统自动识别。 +知识库 URL 格式:`https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview` + +### 列出知识库 +``` +Usage: + dws wiki space list [flags] +Example: + dws wiki space list --format json + dws wiki space list --type myWikiSpace --format json + dws wiki space list --type orgWikiSpace --limit 50 --format json +Flags: + --type string 知识库类型: myWikiSpace / orgWikiSpace (默认 orgWikiSpace) + --limit string 每页数量 1-50 (默认 20) + --page-token string 分页游标 (首页留空) +``` + +- `myWikiSpace`:返回当前用户的「我的文档」个人空间(固定 1 条,不支持分页) +- `orgWikiSpace`(默认):返回组织内有权访问的知识库列表,支持分页 + +### 搜索知识库 +``` +Usage: + dws wiki space search [flags] +Example: + dws wiki space search --keyword "产品文档" --format json + dws wiki space search --keyword "技术方案" --limit 20 --format json + dws wiki space list --type myWikiSpace --format json +Flags: + --keyword string 搜索关键词 (--type myWikiSpace 时可省略) + --type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库 + --limit string 返回数量 1-20 (默认 10) +``` + +当 `--type myWikiSpace` 时,忽略 `--keyword`,直接返回「我的文档」个人空间。 + +### 添加知识库成员(容器级授权) +``` +Usage: + dws wiki member add [flags] +Example: + dws wiki member add --workspace --user uid1 --role READER + dws wiki member add --workspace --user uid1,uid2 --role EDITOR + dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces//overview" --user uid1 --role MANAGER +Flags: + --workspace string 目标知识库 ID 或 URL (必填) + --user strings 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个) + --role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读) +``` + +> **❗ 重要约束**: +> - 仅支持 USER 类型。 +> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。 +> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。 +> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,节点级权限授权在开源 dws v1.0.30 暂不支持,请在钉钉客户端文档中通过「分享」设置。 + +### 修改知识库成员角色 +``` +Usage: + dws wiki member update [flags] +Example: + dws wiki member update --workspace --user uid1 --role EDITOR + dws wiki member update --workspace --user uid1,uid2 --role READER +Flags: + --workspace string 目标知识库 ID 或 URL (必填) + --user strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个) + --role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER +``` + +### 列出知识库成员 +``` +Usage: + dws wiki member list [flags] +Example: + dws wiki member list --workspace + dws wiki member list --workspace --max-results 100 + dws wiki member list --workspace --filter-role EDITOR +Flags: + --workspace string 目标知识库 ID 或 URL (必填) + --max-results int 返回数量上限,最大 200 (默认 50) + --filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填) +``` + +> 接口不支持游标分页,使用 `--max-results` 一次性拉取。 + +## 意图判断 + +- 用户说"创建知识库/新建知识库" → `space create` +- 用户说"查看知识库/知识库详情" → `space get` +- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list` +- 用户说"搜索知识库/找知识库" → `space search` +- 用户说"我的文档/个人空间" → `space list --type myWikiSpace` +- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--user` + `--role`) +- 用户说"修改某人在知识库的权限/调整成员角色" → `member update` +- 用户说"知识库有哪些成员/查看知识库成员" → `member list` + +关键区分: +- wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限) +- wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载) +- **wiki member**(容器级,授权整个知识库);节点级 `doc permission` 在开源 dws v1.0.30 不可用 + - 「我的文档」当前开源版本不能通过 CLI 改权限;在钉钉客户端用「分享」设置 + +## 核心工作流 + +```bash +# 列出我有权访问的组织知识库 +dws wiki space list --format json + +# 获取「我的文档」个人空间 +dws wiki space list --type myWikiSpace --format json + +# 搜索知识库 +dws wiki space search --keyword "产品" --format json + +# 搜索「我的文档」 +dws wiki space list --type myWikiSpace --format json + +# 创建知识库 +dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json + +# 查看知识库详情 +dws wiki space get --id --format json + +# ── 工作流: 给知识库加成员 ── + +# 1. 先确认知识库 ID(避免授权到「我的文档」) +dws wiki space list --format json # 注意:不要 --type myWikiSpace + +# 2. 添加成员 +dws wiki member add --workspace --user --role EDITOR --format json + +# 3. 查看当前成员 +dws wiki member list --workspace --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `space create` | `workspaceId` | space get 的 --workspace / member add 的 --workspace | +| `space list` | `workspaceId` | space get 的 --workspace / member add 的 --workspace | +| `space search` | `workspaceId` | space get 的 --workspace / member add 的 --workspace | +| `space get` | `spaceUrl` | 分享给用户 | +| `member list` | `userId` | member update 的 --user | + +## 相关产品 + +- `dingtalk-doc` (`references/doc.md`) — 文档内容级操作(搜索/读写/编辑文档、知识库内文档管理) +- `dingtalk-drive` (`references/drive.md`) — 钉盘文件存储/上传/下载 diff --git a/skills/references/products/chat.md b/skills/references/products/chat.md deleted file mode 100644 index 13e823db..00000000 --- a/skills/references/products/chat.md +++ /dev/null @@ -1,771 +0,0 @@ -# 会话与群聊 (chat) 命令参考 - -> 命令别名: `dws im` 等价于 `dws chat` - -## 命令总览 - -### group (群组管理) - -| 子命令 | 用途 | -|-------|------| -| `group create` | 创建内部群 | -| `group members` | 查看群成员列表 | -| `group members add` | 添加群成员 | -| `group members remove` | 移除群成员(⚠️ 危险操作) | -| `group members add-bot` | 添加机器人到群 | -| `group rename` | 修改群名称 | -| `search` | 搜索群会话 | -| `search-common` | 搜索共同群 | -| `conversation-info` | 获取会话基础信息(单聊/群聊) | - -### message (会话消息管理) - -| 子命令 | 用途 | -|-------|------| -| `message send` | 以当前用户身份发群消息或单聊消息 | -| `message list` | 拉取群聊或单聊会话消息 | -| `message list-all` | 按时间范围拉取当前用户所有会话消息 | -| `message list-topic-replies` | 拉取群话题回复消息列表 | -| `message list-by-sender` | 搜索指定发送者的消息 | -| `message list-mentions` | 拉取 @我 的消息 | -| `message list-focused` | 拉取特别关注人的消息 | -| `message list-unread-conversations` | 获取未读会话列表 | -| `message search` | 按关键词搜索消息 | -| `message send-by-bot` | 机器人发消息(群聊或批量单聊) | -| `message recall-by-bot` | 机器人撤回消息 | -| `message send-by-webhook` | 自定义机器人 Webhook 发消息 | -| `list-top-conversations` | 拉取置顶会话列表 | - -### bot (机器人管理) - -| 子命令 | 用途 | -|-------|------| -| `bot search` | 搜索我的机器人 | - ---- - -## group create — 创建内部群 - -当前登录用户自动成为群主。 - -``` -Usage: - dws chat group create [flags] -Example: - dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 -Flags: - --users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填) - --name string 群名称 (必填) -``` - ---- - -## group members list — 查看群成员列表 - -分页查询指定群聊的成员。 - -``` -Usage: - dws chat group members list [flags] -Example: - dws chat group members list --id -Flags: - --cursor string 分页游标,首次从 0 开始 - --id string 群 ID / openconversation_id (必填) -``` - -> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。 - ---- - -## group members add — 添加群成员 - -向指定群聊添加成员,需传入群 ID 与用户 ID 列表。 - -``` -Usage: - dws chat group members add [flags] -Example: - dws chat group members add --id --users userId1,userId2 -Flags: - --id string 群 ID / openconversation_id (必填) - --users string 要添加的用户 userId 列表,逗号分隔 (必填) -``` - ---- - -## group members remove — 移除群成员 - -> ⚠️ 危险操作:执行前必须向用户确认,同意后才加 `--yes`。 - -``` -Usage: - dws chat group members remove [flags] -Example: - dws chat group members remove --id --users userId1,userId2 -Flags: - --id string 群 ID / openconversation_id (必填) - --users string 要移除的用户 userId 列表,逗号分隔 (必填) -``` - ---- - -## group members add-bot — 添加机器人到群 - -将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。 - -``` -Usage: - dws chat group members add-bot [flags] -Example: - dws chat group members add-bot --robot-code --id -Flags: - --id string 群聊 openConversationId (必填) - --robot-code string 机器人 Code (必填) -``` - ---- - -## group rename — 修改群名称 - -``` -Usage: - dws chat group rename [flags] -Example: - dws chat group rename --id --name "新群名" -Flags: - --id string 群 ID / openconversation_id (必填) - --name string 修改后的群名称 (必填) -``` - ---- - -## search — 搜索群会话 - -根据名称搜索会话列表。 - -``` -Usage: - dws chat search [flags] -Example: - dws chat search --query "项目冲刺" -Flags: - --cursor string 分页游标 (首页留空) - --query string 搜索关键词 (必填) -``` - ---- - -## search-common — 搜索共同群 - -根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。 - -``` -Usage: - dws chat search-common [flags] -Example: - dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0 - dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0 - dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor -Flags: - --nicks string 要搜索的昵称列表,逗号分隔 (必填) - --match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND) - --limit int 每页返回数量(默认 20) - --cursor string 分页游标(默认 "0",翻页传 nextCursor) - -注意: - - --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔" - - --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可 - - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor -``` - ---- - -## message send — 以当前用户身份发消息 - ---group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。 ---群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。 ---发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。 - -``` -Usage: - dws chat message send [flags] [] -Example: - dws chat message send --group --title "周报" --text "请提交本周日报" - dws chat message send --user --title "提醒" --text "请查收" - dws chat message send --open-dingtalk-id --title "提醒" --text "请查收" - dws chat message send --group --title "通知" "hello" - dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" - dws chat message send --group --title "通知" --at-all "<@all> 请大家注意" - dws chat message send --group --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收" - dws chat message send --group --title "图片" --media-id - dws chat message send --open-dingtalk-id --title "图片" --media-id -Flags: - --text string 消息内容(推荐使用,也可用位置参数) - --group string 群聊 openconversation_id(群聊时必填) - --user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一) - --open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) - --title string 消息标题(必填) - --at-all @所有人(仅群聊时生效,可选,默认 false) - --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选) - --at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选) - --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容 - --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断) - --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id) - --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用) - --file-name string 文件消息的文件名 - --file-size string 文件消息的文件大小(字节) - --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等) - -注意: - - --text 和位置参数二选一,--text 优先 - - --title 必填(群聊与单聊都必填,API 强制要求) - - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id - - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2> - - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user - - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file -``` - ---- - -## message list — 拉取会话消息内容 - -拉取指定群聊或单聊的会话消息内容。 - ---group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。 - -``` -Usage: - dws chat message list [flags] -Example: - # 拉取群聊中某个时间点之后的消息 - dws chat message list --group --time "2025-03-01 00:00:00" - # 拉取单聊消息(通过 userId) - dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 - # 拉取单聊消息(通过 openDingTalkId) - dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 - # 拉取某个时间点之前的消息(向过去翻页) - dws chat message list --group --time "2025-03-01 00:00:00" --forward=false -Flags: - --forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true) - --group string 群聊 openconversation_id(群聊时必填) - --limit int 返回数量,不传则不限制 - --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息) - --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一) - --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景) - -注意: - - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id - - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数 -``` - -### 分页翻页说明(重要) - -`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆: - -| 命令 | 翻页参数 | 翻页值来源 | 值格式 | -|------|---------|-----------|--------| -| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) | -| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) | - -**翻页步骤(message list):** - -1. **首次请求**:指定起始时间 - ```bash - dws chat message list --group --time "2025-03-01 00:00:00" --limit 50 --format json - ``` -2. **检查响应**:查看 `hasMore` 字段 - - `hasMore: false` → 没有更多消息,翻页结束 - - `hasMore: true` → 还有更多消息,继续下一步 -3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`) -4. **下一页请求**:将该 `createTime` 作为 `--time` 传入 - ```bash - dws chat message list --group --time "2025-03-01 14:30:00" --limit 50 --format json - ``` -5. 重复步骤 2-4 直到 `hasMore: false` - -> ⚠️ **常见错误**: -> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。 -> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。 - ---- - -## message list-all — 拉取指定时间范围内当前用户的所有会话消息 - -分页拉取当前登录用户在指定时间范围内的所有会话消息。 - ---start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。 - -``` -Usage: - dws chat message list-all [flags] -Example: - dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 - dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" -Flags: - --start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填) - --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填) - --limit int 每页返回数量(默认 50) - --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取) - -注意: - - 四个参数每次请求都会传递给服务端,cursor 首页传 "0" - - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息 - - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页 - - 时间格式统一为 yyyy-MM-dd HH:mm:ss -``` - ---- - -## message list-topic-replies — 拉取群话题回复消息列表 - -查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。 - -``` -Usage: - dws chat message list-topic-replies [flags] -Example: - dws chat message list-topic-replies --group --topic-id - dws chat message list-topic-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20 -Flags: - --group string 群会话 openconversationId (必填) - --topic-id string 话题 ID,由 dws chat message list 返回 (必填) - --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选) - --limit int 返回数量(默认 50) - --forward true=从老往新,false=从新往老(默认 false) -``` - ---- - -## message list-by-sender — 拉取指定发送者的消息 - -搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。 - -``` -Usage: - dws chat message list-by-sender [flags] -Example: - dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 - dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 -Flags: - --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) - --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一) - --start string 开始时间,ISO-8601 格式 (必填) - --end string 结束时间,ISO-8601 格式 (必填) - --limit int 每页返回数量(默认 50) - --cursor string 分页游标(默认 "0",翻页传 nextCursor) - -注意: - - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一 - - 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识 - - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等 - - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor -``` - ---- - -## message list-mentions — 拉取 @我 的消息 - -搜索时间范围内 @我 的消息,可选指定群聊。 - -``` -Usage: - dws chat message list-mentions [flags] -Example: - dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 - dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 -Flags: - --group string 群聊 openconversation_id(可选,不传则查全部) - --start string 开始时间,ISO-8601 格式 (必填) - --end string 结束时间,ISO-8601 格式 (必填) - --limit int 每页返回数量(默认 50) - --cursor string 分页游标(默认 "0",翻页传 nextCursor) - -注意: - - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊 - - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor -``` - ---- - -## message list-focused — 拉取特别关注人的消息 - -拉取当前用户特别关注人的消息。 - -``` -Usage: - dws chat message list-focused [flags] -Example: - dws chat message list-focused --limit 50 - dws chat message list-focused --limit 20 --cursor -Flags: - --limit int 每页返回数量(默认 50) - --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor) - -注意: - - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor -``` - ---- - -## message list-unread-conversations — 获取未读会话列表 - -获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。 - -``` -Usage: - dws chat message list-unread-conversations [flags] -Example: - dws chat message list-unread-conversations - dws chat message list-unread-conversations --count 20 -Flags: - --count int 返回未读会话条数(可选) -``` - ---- - -## message search — 按关键词搜索消息 - -在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。 - -``` -Usage: - dws chat message search [flags] -Example: - dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 - dws chat message search --keyword "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 -Flags: - --keyword string 搜索关键词 (必填) - --group string 群聊 openconversation_id(可选,不传则搜索所有会话) - --start string 开始时间,ISO-8601 格式 (必填) - --end string 结束时间,ISO-8601 格式 (必填) - --limit int 每页返回数量(默认 100) - --cursor string 分页游标(默认 "0",翻页传 nextCursor) - -注意: - - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话 - - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor -``` - ---- - -## conversation-info — 获取会话基础信息 - -按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。 - -``` -Usage: - dws chat conversation-info [flags] -Example: - dws chat conversation-info --group - dws chat conversation-info --open-dingtalk-id -Flags: - --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一) - --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一) - -注意: - - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一 -``` - ---- - -## list-top-conversations — 拉取置顶会话列表 - -拉取当前用户的置顶会话列表。 - -``` -Usage: - dws chat list-top-conversations [flags] -Example: - dws chat list-top-conversations --limit 1000 - dws chat list-top-conversations --limit 1000 --cursor -Flags: - --limit int 每页返回数量(默认 1000) - --cursor int 分页游标(首次不传或传 0,翻页传 nextCursor) - -注意: - - 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可 - - 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group ` 分别拉取每个会话内的消息 - - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor -``` - ---- - -## bot search — 搜索我的机器人 - -``` -Usage: - dws chat bot search [flags] -Example: - dws chat bot search --page 1 - dws chat bot search --page 1 --size 10 --name "日报" -Flags: - --name string 按名称搜索 - --page int 页码,从1开始 (默认 1) - --size int 每页条数 (默认 50),别名: --limit -``` - ---- - -## message send-by-bot — 机器人发消息 - -群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。 - -``` -Usage: - dws chat message send-by-bot [flags] -Example: - dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..." - dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报" -Flags: - --group string 群聊 openConversationId(群聊时必填) - --robot-code string 机器人 Code (必填) - --text string 消息内容 Markdown (必填) - --title string 消息标题 (必填) - --users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填) - -注意: - - --group 与 --users 互斥,必须且只能指定其一 - - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) -``` - ---- - -## message recall-by-bot — 机器人撤回消息 - -群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。 - -``` -Usage: - dws chat message recall-by-bot [flags] -Example: - dws chat message recall-by-bot --robot-code --group --keys - dws chat message recall-by-bot --robot-code --keys key1,key2 -Flags: - --group string 群聊 openConversationId(群聊撤回时必填) - --keys string 消息 processQueryKey 列表,逗号分隔 (必填) - --robot-code string 机器人 Code (必填) -``` - ---- - -## message send-by-webhook — 自定义机器人 Webhook 发消息 - -@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。 - -``` -Usage: - dws chat message send-by-webhook [flags] -Example: - dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all - dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785 -Flags: - --at-all @ 所有人 - --at-mobiles string @ 指定手机号,逗号分隔 - --at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId) - --text string 消息内容 (必填) - --title string 消息标题 (必填) - --token string Webhook Token (必填) -``` - ---- - -## 意图判断 - -用户说"建群/创建群聊" → `chat group create` -用户说"搜索群/找群" → `chat search` -用户说"群成员/看群里有谁" → `chat group members list` -用户说"拉人进群/加群成员" → `chat group members add` -用户说"踢人/移除群成员" → `chat group members remove` -用户说"加机器人到群" → `chat group members add-bot` -用户说"改群名" → `chat group rename` -用户说"聊天记录/会话消息/拉取会话" → `chat message list` -用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊) -用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用) -用户说"@我的消息/at我的/提及我的" → `chat message list-mentions` -用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations` -用户说"发群消息(以个人身份)" → `chat message send --group` -用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时) -用户说"机器人发消息/机器人群发" → `chat message send-by-bot` -用户说"机器人撤回消息" → `chat message recall-by-bot` -用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook` -用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies` -用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all` -用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused` -用户说"查看我的机器人" → `chat bot search` -用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search` -用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common` -用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations` -用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info` - -关键区分: -- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页 -- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用) -- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用) -- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群) -- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`) -- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令 -- `chat message list-topic-replies` — 拉取群话题的回复消息列表 -- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页 -- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页 -- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息 -- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群) -- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在) -- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag -- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息 -- `chat message recall-by-bot` — 通过机器人撤回已发送的消息 -- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等) -- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot - -## 核心工作流 - -```bash -# 1. 搜索群 — 提取 openconversation_id -dws chat search --query "项目冲刺" --format json - -# 2. 拉取群消息 -dws chat message list --group --time "2025-03-01 00:00:00" --format json - -# 2b. 拉取未读会话列表 -dws chat message list-unread-conversations --count 20 --format json - -# 3. 以个人身份发送群消息 -dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" --format json - -# 4. 以个人身份单聊(通过 userId) -dws chat message send --user --title "问候" "你好" --format json - -# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用) -dws chat message send --open-dingtalk-id --title "问候" "你好" --format json - -# 5. 机器人发群消息(Markdown) -dws chat message send-by-bot --robot-code \ - --group --title "日报" --text "## 今日完成..." --format json - -# 6. 机器人单聊发消息 -dws chat message send-by-bot --robot-code \ - --users userId1,userId2 --title "提醒" --text "请提交周报" --format json - -# 7. Webhook 发告警 -dws chat message send-by-webhook --token \ - --title "告警" --text "CPU 超 90%" --at-all --format json -``` - -## 复合工作流 - -### 机器人发消息后撤回(完整流程) - -撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。 - -```bash -# Step 1: 查我的机器人 — 提取 robot-code -dws chat bot search --format json - -# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey -dws chat message send-by-bot --robot-code --group \ - --title "通知" --text "内容" --format json - -# Step 3: 用同一个 robot-code + processQueryKey 撤回 -dws chat message recall-by-bot --robot-code --group \ - --keys --format json -``` - -### 将已有机器人加入群并发消息(完整流程) - -机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。 - -```bash -# Step 1: 搜索我的机器人 — 提取 robotCode -dws chat bot search --name "项目提醒" --format json - -# Step 2: 搜索群 — 提取 openConversationId -dws chat search --query "项目群" --format json - -# Step 3: 将机器人添加到群(需当前用户对该群有管理权限) -dws chat group members add-bot --id --robot-code --format json - -# Step 4: 机器人发消息 -dws chat message send-by-bot --robot-code --group \ - --title "提醒" --text "请及时更新项目状态" --format json -``` - -### 机器人 @指定人发群消息 - -`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。 - -```bash -# Step 1: 搜人获取 userId -dws aisearch person --keyword "张三" --dimension name --format json - -# Step 2: 取 userId 发送(注意 text 中的占位符) -dws chat message send-by-bot --robot-code --group \ - --title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json -``` - -### 发送图片/文件消息(跨产品: drive → chat) - -```bash -# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证 -dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json - -# Step 2: HTTP PUT 上传文件到 OSS -curl -X PUT -T "截图.png" "" - -# Step 3: 提交上传 — 获取 dentryUuid -dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id --format json - -# Step 4: 获取下载链接 -dws drive download --file-id --format json - -# Step 5: 用 Markdown 图片语法发送 -dws chat message send --group \ - --title "截图" --text "![截图](下载链接)" --format json -``` - -## 上下文传递表 - -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `chat search` | `openConversationId` | message send/list、group members 等的 --group | -| `chat group create` | `openConversationId` | 同上 | -| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor | -| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id | -| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id | -| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code | -| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | -| `chat conversation-info` | `openConversationId` / 成员信息 | 作为 message send/list 等后续操作的会话上下文确认 | -| `chat message search` | `nextCursor` | 下次 message search 的 --cursor | -| `chat search-common` | `openConversationId` | message send/list 等的 --group | -| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 | - -## 注意事项 - -- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取 -- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id` -- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor -- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一 -- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识 -- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部 -- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数 -- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话 -- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑 -- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor` -- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一 -- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) -- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效 - -## 自动化脚本 - -| 脚本 | 场景 | 用法 | -|------|------|------| -| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | -| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | - -## 相关产品 - -- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id -- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息 diff --git a/skills/references/products/sheet.md b/skills/references/products/sheet.md deleted file mode 100644 index 7bba0036..00000000 --- a/skills/references/products/sheet.md +++ /dev/null @@ -1,304 +0,0 @@ -# 在线电子表格 (sheet) 命令参考 - -## 适用范围(重要) - -`sheet` 产品**仅支持钉钉在线电子表格**(`contentType=ALIDOC`、`extension=axls`),**不支持**上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。 - -| 文件类型 | 处理方式 | -|---------|---------| -| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) | -| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 | -| 想把在线表格导出为 xlsx | 用 `dws sheet submit_export_job` 提交导出任务 → 拿到 `jobId` → `dws sheet query_export_job` 轮询直到 `finished` → 用返回的 `downloadUrl` 下载 | - -> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node --format json` 确认 `contentType=ALIDOC` 且 `extension=axls` 后再走 `sheet`;否则转 `dws doc download`。 - -## 命名风格说明(v1.0.25 envelope 现状) - -`sheet` 产品的命令 cli_name **当前同时存在两种风格**——这是 envelope schema 还在演进中、`CLIAliases` (#246) 重命名尚未完成的过渡态: - -- **kebab-case** (~17 个):`add-dimension`、`merge-cells`、`filter-view update-criteria` 等,与 dws 其它产品风格一致 -- **snake_case** (~12 个):`copy_sheet`、`submit_export_job`、`set_filter_criteria` 等,envelope 原始名直透出来 - -**调用时以 `dws sheet --help` 输出为准**——本文档与 envelope schema 同步,未来命名收敛后会同步更新。所有命令的最终参数名以 `dws sheet --help` 和 `dws schema sheet.` 为准。 - -## 命令总览(按功能分组) - -### 工作表 (Worksheet) 级 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet create` | `sheet.create_workspace_sheet` | 在知识库中创建一个新的钉钉表格文档 | -| `dws sheet new` | `sheet.create_sheet` | 在已有钉钉表格文档中新建一张工作表 | -| `dws sheet list` | `sheet.get_all_sheets` | 列出指定文档的所有工作表 | -| `dws sheet info` | `sheet.get_sheet` | 获取指定工作表详情 | -| `dws sheet copy_sheet` | `sheet.copy_sheet` | 复制工作表(同文档内) | -| `dws sheet update_sheet` | `sheet.update_sheet` | 更新工作表元信息(如改名) | - -### 区域 (Range) 读写 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet range read` | `sheet.get_range` | 读取指定区域的单元格内容 | -| `dws sheet range update` | `sheet.update_range` | 写入/更新指定区域的单元格 | -| `dws sheet append` | `sheet.append_rows` | 在工作表末尾追加若干行 | - -### 行列 (Dimension) - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet add-dimension` | `sheet.add_dimension` | 在末尾追加空行或空列 | -| `dws sheet insert-dimension` | `sheet.insert_dimension` | 在指定位置插入空行/空列 | -| `dws sheet delete-dimension` | `sheet.delete_dimension` | 删除指定位置起的若干行/列 | -| `dws sheet move-dimension` | `sheet.move_dimension` | 移动行/列到指定位置 | -| `dws sheet update-dimension` | `sheet.update_dimension` | 更新行/列属性(显隐、行高/列宽) | - -### 单元格合并 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet merge-cells` | `sheet.merge_cells` | 合并指定范围的单元格(`mergeAll`/`mergeRows`/`mergeColumns`) | -| `dws sheet unmerge-cells` | `sheet.unmerge_range` | 取消指定范围的合并 | - -### 查找/替换 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet find` | `sheet.find_cells` | 在工作表中搜索单元格内容(支持正则/整格匹配/隐藏) | -| `dws sheet replace` | `sheet.replace_all` | 全局查找替换 | - -### 筛选视图 (Filter View) — 命名视图、按列条件、不影响表本身 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet filter-view create` | `sheet.create_filter_view` | 创建筛选视图 | -| `dws sheet filter-view list` | `sheet.get_filter_views` | 列出工作表的所有筛选视图 | -| `dws sheet filter-view update` | `sheet.update_filter_view` | 更新筛选视图(名称/范围/条件) | -| `dws sheet filter-view delete` | `sheet.delete_filter_view` | 删除整个筛选视图 | -| `dws sheet filter-view update-criteria` | `sheet.set_filter_view_criteria` | 设置/更新视图内某列的筛选条件 | -| `dws sheet filter-view delete-criteria` | `sheet.clear_filter_view_criteria` | 清除视图内某列的筛选条件 | - -### 表级筛选 (Filter) — 直接作用于工作表本身的临时筛选 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet create_filter` | `sheet.create_filter` | 在工作表上创建筛选器 | -| `dws sheet get_filter` | `sheet.get_filter` | 获取当前筛选器配置 | -| `dws sheet update_filter` | `sheet.update_filter` | 更新筛选器条件 | -| `dws sheet delete_filter` | `sheet.delete_filter` | 删除筛选器 | -| `dws sheet set_filter_criteria` | `sheet.set_filter_criteria` | 设置某列的筛选条件 | -| `dws sheet clear_filter_criteria` | `sheet.clear_filter_criteria` | 清除某列的筛选条件 | -| `dws sheet sort_filter` | `sheet.sort_filter` | 对筛选范围按指定列排序 | - -### 图片 - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet write-image` | `sheet.write_image` | 将已上传的图片资源写入指定单元格 | - -### 导出(两步原子,**v1.0.25 没有合并的 `export` 命令**) - -| 命令 | canonical | 用途 | -|------|-----------|------| -| `dws sheet submit_export_job` | `sheet.submit_export_job` | 提交导出任务,返回 `jobId` | -| `dws sheet query_export_job` | `sheet.query_export_job` | 轮询导出任务状态,完成后返回 `downloadUrl` | - -> v1.0.25 envelope 暴露的是这两个**原子动作**。要完整完成"导出 → 下载"流程需要 client 侧轮询 + 调 `downloadUrl`。`CLIToolOverride.Pipeline` (#247) 提供了底层编排能力,但**当前 envelope 还没把这两个动作 Pipeline 化成一条 `dws sheet export` 总命令**。 - -## 通用必填参数 - -绝大多数 `sheet` 命令都需要: - -- `--node ` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/` URL(alias 来自 `nodeId`) -- `--sheet-id ` —— 工作表 ID(alias 来自 `sheetId`),从 `dws sheet list` 拿 - -例外: -- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId) -- `list` / `info` / `range read` 只需要 `--node` -- `submit_export_job` 只需要 `--node` + `--export-format`(无 sheet-id) -- `query_export_job` 只需要 `--job-id` - -## 常用命令示例 - -### 创建文档 + 新建工作表 - -```bash -# 在知识库下创建一个钉钉表格文档 -dws sheet create --name "销售数据" --workspace --format json -# 返回的 nodeId 用于后续操作 - -# 在已有文档中新建一张工作表 -dws sheet new --node --name "Q1 数据" --format json -``` - -### 读写区域 - -```bash -# 读 A1:D10 -dws sheet range read --node --sheet-id --range "A1:D10" --format json - -# 写入 5x4 区域(values 是二维 JSON 数组) -dws sheet range update --node --sheet-id --range "A1:D5" \ - --values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \ - --format json - -# 追加行 -dws sheet append --node --sheet-id \ - --values '[["李四","产品","2025-03","28000"]]' \ - --format json -``` - -### 行列操作 - -```bash -# 在第 5 行处插入 2 个空行 -dws sheet insert-dimension --node --sheet-id \ - --dimension rows --position 4 --length 2 --format json - -# 末尾追加 3 列 -dws sheet add-dimension --node --sheet-id \ - --dimension columns --length 3 --format json - -# 删除第 10-12 行 -dws sheet delete-dimension --node --sheet-id \ - --dimension rows --position 9 --length 3 --format json - -# 隐藏 B 列(startIndex=1, length=1) -dws sheet update-dimension --node --sheet-id \ - --dimension columns --start-index 1 --length 1 --hidden true --format json -``` - -### 合并/取消合并 - -```bash -# 合并 A1:C1 -dws sheet merge-cells --node --sheet-id \ - --range "A1:C1" --merge-type mergeAll --format json - -# 取消合并 -dws sheet unmerge-cells --node --sheet-id \ - --range "A1:C1" --format json -``` - -### 查找/替换 - -```bash -# 查找 -dws sheet find --node --sheet-id \ - --find "TODO" --use-regexp false --match-case false --format json - -# 全局替换 -dws sheet replace --node --sheet-id \ - --find "TODO" --replacement "DONE" --format json -``` - -### 筛选视图(推荐:可命名、不破坏原表) - -```bash -# 创建筛选视图(范围必须包含表头行) -dws sheet filter-view create --node --sheet-id \ - --name "未完成项" --range "A1:E100" --format json -# 返回的 filterViewId 用于后续 update/delete/criteria 操作 - -# 列出所有筛选视图 -dws sheet filter-view list --node --sheet-id --format json - -# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移) -dws sheet filter-view update-criteria --node --sheet-id \ - --filter-view-id --column 2 \ - --filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \ - --format json - -# 清除某列的筛选条件(不删除视图本身) -dws sheet filter-view delete-criteria --node --sheet-id \ - --filter-view-id --column 2 --format json - -# 删除整个筛选视图 -dws sheet filter-view delete --node --sheet-id \ - --filter-view-id --format json -``` - -### 表级筛选(snake_case 系列,直接作用于工作表本身) - -```bash -# 创建筛选器(一张表只有一个,再次 create 会替换) -dws sheet create_filter --node --sheet-id \ - --range "A1:E100" --format json - -# 给某列加筛选条件 -dws sheet set_filter_criteria --node --sheet-id \ - --column 0 --filter-criteria '{...}' --format json - -# 按指定列排序 -dws sheet sort_filter --node --sheet-id --field 0 --format json - -# 删除筛选器 -dws sheet delete_filter --node --sheet-id --format json -``` - -### 复制工作表 - -```bash -dws sheet copy_sheet --node --sheet-id --format json -# 返回新工作表 ID -``` - -### 写入图片 - -```bash -# 已有图片资源 ID 和 URL(通过 drive 或 doc 上传得到)后写入单元格 -dws sheet write-image --node --sheet-id \ - --range "B2:B2" --resource-id --resource-url \ - --width 200 --height 100 --format json -``` - -### 导出 xlsx(两步流程) - -```bash -# Step 1: 提交导出任务 -JOB=$(dws sheet submit_export_job --node --export-format xlsx --format json --jq '.result.jobId') - -# Step 2: 轮询任务状态(建议 sleep + 重试) -dws sheet query_export_job --job-id "$JOB" --format json -# 直到返回 status=finished + downloadUrl - -# Step 3: 下载(用 curl / dws doc download / 其它工具拉 downloadUrl) -``` - -## 易混淆点 - -| 区分 | 说明 | -|---|---| -| `dws sheet create` vs `dws sheet new` | `create` 在知识库**新建一个文档**(返回新 nodeId);`new` 在**已有文档中新建一张工作表**(需 nodeId) | -| `dws sheet filter-view *` vs `dws sheet create_filter`/`set_filter_criteria` 等 | filter-view 是**命名视图**,多个并存、不影响表本身;filter 是**表级唯一**筛选器,直接作用于工作表显示 | -| `filter-view update-criteria` vs `filter-view delete-criteria` | update 是设置/覆盖列条件;delete 是清除列条件(视图本身保留);要删整个视图用 `filter-view delete` | -| `dws sheet submit_export_job` + `query_export_job` vs `dws sheet export` | 后者**不存在**于 v1.0.25 envelope。需 client 端自己轮询,或基于 Pipeline (#247) 在 envelope 侧 PR 一条总命令 | -| `dws sheet write-image` vs `range update` | write-image 写入图片(需 resourceId + resourceUrl);range update 写入文本/数字/公式 | -| `range update` vs `append` | range update 指定区域覆盖;append 在末尾追加行 | -| online axls vs 本地 xlsx | sheet 全部命令只认 axls;本地 xlsx 必须先 `doc download` 再用本地工具解析 | - -## 危险操作(必须先向用户确认) - -| 命令 | 风险 | -|---|---| -| `delete-dimension` | 删除行/列(含数据),不可恢复 | -| `filter-view delete` | 删除整个筛选视图 | -| `delete_filter` | 删除表级筛选器 | -| `replace` | 全局替换可能影响大量单元格 | -| `unmerge-cells` | 取消合并可能丢失部分单元格内容(钉钉行为依赖合并模式) | -| `update_sheet` | 更新工作表元信息(如改名) | - -执行前先 `--dry-run` 预览,并向用户展示操作摘要 + 拿到明确同意,再加 `--yes` 提交。 - -## 何时**不要**用 sheet - -- 用户给的是 `xlsx` / `xls` / `xlsm` / `csv` 本地文件 → 用 `dws doc download` 下载后本地解析 -- 用户给的是 AI 表格(不是在线电子表格)→ 用 `dws aitable record query` 等 -- 用户给的是富文本/普通文档 → 用 `dws doc read` - -## 权威参考 - -- 列出所有 sheet 工具:`dws schema | jq '.products[] | select(.id=="sheet") | .tools[] | "\(.group) \(.cli_name)"' -r` -- 看某个命令的完整 JSON Schema:`dws schema sheet.`(如 `dws schema sheet.update_range`) -- 看某个命令的 flag 别名映射:`dws schema sheet. --jq '.tool.flag_overlay'` -- 看必填字段:`dws schema sheet. --jq '.tool.required'` -- 命令的人读视图:`dws sheet --help` diff --git a/skills/references/products/workbench.md b/skills/references/products/workbench.md deleted file mode 100644 index 58ddc610..00000000 --- a/skills/references/products/workbench.md +++ /dev/null @@ -1,41 +0,0 @@ -# 工作台 (workbench) 命令参考 - -> ⚠️ **Draft**:以下命令在当前 CLI 运行时中尚未上线(`dws workbench --help` 回退到根帮助页面),文档仅供预览,待服务端注册后生效。 - -## 命令总览 - -### 查看所有工作台应用 -``` -Usage: - dws workbench app list [flags] -Example: - dws workbench app list -``` -### 批量获取应用详情 -``` -Usage: - dws workbench app get [flags] -Example: - dws workbench app get --ids , -Flags: - --ids string 应用 ID 列表 (必填) -``` - -## 意图判断 - -用户说"工作台有什么应用/看看应用" → `app list` -用户说"应用详情" → `app get` (需 appId) - -## 核心工作流 - -```bash -# 查看所有应用 — 提取 appId -dws workbench app list --format json - -# 获取应用详情 -dws workbench app get --ids app1,app2 --format json -``` -## 上下文传递表 -| 操作 | 提取 | 用于 | -|------|------|------| -| `app list` | `appId` | app get 的 --ids | diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index c53c6e7e..0939ef51 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -196,12 +196,12 @@ func TestInstallScriptsUseFlattenedSkillsSourceRoot(t *testing.T) { }{ { relPath: filepath.Join("..", "..", "scripts", "install.sh"), - want: `skill_src="${root}/skills"`, + want: `skill_src="${root}/skills/mono"`, avoid: `skill_src="${root}/skills/${SKILL_NAME}"`, }, { relPath: filepath.Join("..", "..", "scripts", "install.ps1"), - want: `$skillSrc = Join-Path $Root "skills"`, + want: `$skillSrc = Join-Path (Join-Path $Root "skills") "mono"`, avoid: `$skillSrc = Join-Path $Root "skills\$SkillName"`, }, } @@ -227,6 +227,55 @@ func TestInstallScriptsUseFlattenedSkillsSourceRoot(t *testing.T) { } } +func TestInstallScriptsExposeSkillModeSelection(t *testing.T) { + t.Parallel() + + shPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) + if err != nil { + t.Fatalf("Abs(install.sh) error = %v", err) + } + shData, err := os.ReadFile(shPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", shPath, err) + } + shText := string(shData) + + // install.sh must honor DWS_SKILL_MODE, expose mono/multi, and check TTY via [ -t 0 ]. + for _, want := range []string{ + "DWS_SKILL_MODE", + "mono", + "multi", + "[ -t 0 ]", + "dws skill setup --mode multi", + } { + if !strings.Contains(shText, want) { + t.Fatalf("install.sh missing %q (needed for skill mode selection)", want) + } + } + + ps1Path, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatalf("Abs(install.ps1) error = %v", err) + } + ps1Data, err := os.ReadFile(ps1Path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", ps1Path, err) + } + ps1Text := string(ps1Data) + + for _, want := range []string{ + "DWS_SKILL_MODE", + "mono", + "multi", + "IsInputRedirected", + "dws skill setup --mode multi", + } { + if !strings.Contains(ps1Text, want) { + t.Fatalf("install.ps1 missing %q (needed for skill mode selection)", want) + } + } +} + func TestBuildEntrypointsUseStripLdflags(t *testing.T) { t.Parallel() @@ -261,6 +310,138 @@ func TestBuildEntrypointsUseStripLdflags(t *testing.T) { } } +// TestInstallScriptsCacheMultiSkills verifies install.sh / install.ps1 / +// install-skills.sh / build/npm/install.js all carry the wiring that caches +// the multi/ tree to ~/.dws/skills/multi/ during install. This is what lets +// `dws skill setup --mode multi` find a source on a fresh machine. +func TestInstallScriptsCacheMultiSkills(t *testing.T) { + t.Parallel() + + checks := []struct { + relPath string + wants []string + }{ + { + relPath: filepath.Join("..", "..", "scripts", "install.sh"), + wants: []string{ + "cache_multi_skills", + "${HOME}/.dws/skills/multi", + "cache_mono_skills", + }, + }, + { + relPath: filepath.Join("..", "..", "scripts", "install.ps1"), + wants: []string{ + "Cache-MultiSkills", + ".dws\\skills\\multi", + "Cache-MonoSkills", + }, + }, + { + relPath: filepath.Join("..", "..", "scripts", "install-skills.sh"), + wants: []string{ + "${DWS_CACHE_ROOT}/skills/multi", + "${DWS_CACHE_ROOT}/skills/mono", + }, + }, + { + relPath: filepath.Join("..", "..", "build", "npm", "install.js"), + wants: []string{ + "cacheUserSkills", + ".dws", + "\"multi\"", + "\"mono\"", + }, + }, + } + + for _, tc := range checks { + scriptPath, err := filepath.Abs(tc.relPath) + if err != nil { + t.Fatalf("Abs(%s) error = %v", tc.relPath, err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptPath, err) + } + text := string(data) + for _, want := range tc.wants { + if !strings.Contains(text, want) { + t.Fatalf("%s missing %q (needed for multi-skill caching)", scriptPath, want) + } + } + } +} + +// TestInstallScriptCachesMultiEndToEnd runs install.sh in source-checkout mode +// with a fake HOME, then verifies that ~/.dws/skills/multi/ ends up populated +// with the per-product skills from skills/multi/. +func TestInstallScriptCachesMultiEndToEnd(t *testing.T) { + t.Parallel() + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) + if err != nil { + t.Fatalf("Abs(install.sh) error = %v", err) + } + + stubRoot := filepath.Join(root, "stubs") + repoRoot, _ := filepath.Abs(filepath.Join("..", "..")) + makeStub := `#!/bin/sh +set -eu +dir="" +while [ "$#" -gt 0 ]; do + case "$1" in + -C) dir="$2"; shift 2 ;; + *) shift ;; + esac +done +[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws" +` + mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755) + mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755) + + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "DWS_INSTALL_DIR="+installDir, + ) + output, err := cmd.CombinedOutput() + + // Clean up the fake binary created in the real repo root + _ = os.Remove(filepath.Join(repoRoot, "dws")) + + if err != nil { + t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) + } + + // Verify multi cache was populated. We expect dingtalk-* subdirs. + multiCache := filepath.Join(fakeHome, ".dws", "skills", "multi") + entries, err := os.ReadDir(multiCache) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v\noutput:\n%s", multiCache, err, string(output)) + } + foundDingtalk := 0 + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") { + foundDingtalk++ + } + } + if foundDingtalk == 0 { + t.Fatalf("no dingtalk-* entries under %s: %v\noutput:\n%s", multiCache, entries, string(output)) + } + + // And verify mono cache. + monoCacheSkill := filepath.Join(fakeHome, ".dws", "skills", "mono", "SKILL.md") + if _, err := os.Stat(monoCacheSkill); err != nil { + t.Fatalf("missing mono cache SKILL.md at %s: %v", monoCacheSkill, err) + } +} + func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 91ebe9d3..3117768c 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -235,10 +235,96 @@ func TestPostGoreleaserUsesFlattenedSkillsSourceRoot(t *testing.T) { } text := string(data) - if !strings.Contains(text, `cd "$ROOT/skills"`) { - t.Fatalf("post-goreleaser.sh missing flattened skills source root:\n%s", text) + // The new layout copies skills/mono/ to staging root + staging/mono/, so we + // no longer have a single `cd "$ROOT/skills/mono"`. Instead verify the + // staging-based create_skills_zip references both source trees explicitly. + for _, want := range []string{ + `cp -R "$ROOT/skills/mono/." "$staging/"`, + `cp -R "$ROOT/skills/mono/." "$staging/mono/"`, + `cp -R "$ROOT/skills/multi/." "$staging/multi/"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("post-goreleaser.sh missing skills layout line %q:\n%s", want, text) + } } if strings.Contains(text, `cd "$ROOT/skills/dws"`) { t.Fatalf("post-goreleaser.sh still references legacy nested skills root:\n%s", text) } } + +// TestPostGoreleaserSkillsZipLayout exercises create_skills_zip end-to-end: +// runs post-goreleaser.sh against a tempdir, unzips dws-skills.zip, and +// verifies that the new zip layout contains (a) mono content at the root for +// backward compatibility, (b) an explicit mono/ subtree, and (c) a multi/ +// subtree carrying per-product skills. +func TestPostGoreleaserSkillsZipLayout(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh")) + if err != nil { + t.Fatalf("Abs(post-goreleaser.sh) error = %v", err) + } + + root := t.TempDir() + distDir := filepath.Join(root, "dist") + + hostOS := runtime.GOOS + hostArch := runtime.GOARCH + archiveName := "dws-" + hostOS + "-" + hostArch + ".tar.gz" + if hostOS == "windows" { + archiveName = "dws-" + hostOS + "-" + hostArch + ".zip" + } + seedDistArtifacts(t, distDir, []string{archiveName}) + + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "DWS_PACKAGE_DIST_DIR="+distDir, + "DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v0.0.0", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output)) + } + + skillsZip := filepath.Join(distDir, "dws-skills.zip") + extractDir := filepath.Join(root, "skills-extract") + if err := os.MkdirAll(extractDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) = %v", extractDir, err) + } + if out, err := exec.Command("unzip", "-q", skillsZip, "-d", extractDir).CombinedOutput(); err != nil { + t.Fatalf("unzip dws-skills.zip error = %v: %s", err, string(out)) + } + + // Backward compat: zip root must carry mono content. + for _, rel := range []string{"SKILL.md", "references", "scripts"} { + if _, err := os.Stat(filepath.Join(extractDir, rel)); err != nil { + t.Fatalf("zip root missing %s (backward compat broken): %v", rel, err) + } + } + // Explicit mono/ subdir. + if _, err := os.Stat(filepath.Join(extractDir, "mono", "SKILL.md")); err != nil { + t.Fatalf("zip missing mono/SKILL.md: %v", err) + } + // multi/ subtree with at least one per-product skill. + multiEntries, err := os.ReadDir(filepath.Join(extractDir, "multi")) + if err != nil { + t.Fatalf("ReadDir multi/ error = %v", err) + } + if len(multiEntries) == 0 { + t.Fatalf("multi/ is empty; expected per-product skills") + } + foundDingtalk := false + for _, e := range multiEntries { + if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") { + foundDingtalk = true + skillFile := filepath.Join(extractDir, "multi", e.Name(), "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + t.Fatalf("missing %s: %v", skillFile, err) + } + break + } + } + if !foundDingtalk { + t.Fatalf("multi/ does not contain any dingtalk-* skill: %v", multiEntries) + } +} diff --git a/test/skill_e2e/skill_e2e_test.go b/test/skill_e2e/skill_e2e_test.go new file mode 100644 index 00000000..4e8aa300 --- /dev/null +++ b/test/skill_e2e/skill_e2e_test.go @@ -0,0 +1,412 @@ +//go:build skill_verify +// +build skill_verify + +// Package skill_e2e_test runs every backtick `dws ...` command from skill +// docs through the real cobra binary in --mock mode, then asserts the +// returned envelope is well-formed and success=true. +// +// Build tag rationale: this test depends on a real `dws` binary on PATH +// and invokes ~220 subprocesses (slow). Default `go test ./...` skips it. +// Run explicitly: `go test -tags skill_verify ./test/skill_e2e/...` +// +// This is Layer B of the skill verification matrix — pure open-source, +// no real tenant required. Layer A (test/skill_static) checks help-text +// dispatch; this layer actually invokes each command end-to-end and +// validates the envelope/JSON response. +// +// Why --mock instead of real calls: open-source CI has no real DingTalk +// tenant, and depending on one would either (a) leak credentials or +// (b) make the test flaky on tenant drift. --mock returns a deterministic +// shape (`{"_mock": true, "_tool": "", "result": [], "success": true}`) +// for every cobra command that resolves cleanly. Any real dispatch +// failure (unknown flag, unknown command, broken envelope) surfaces +// here exactly as it would on a real tenant. +package skill_e2e_test + +import ( + "bufio" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "testing" +) + +var ( + backtickRe = regexp.MustCompile("`(dws\\s+[^`]+?)`") + quotedRe = regexp.MustCompile(`"[^"]*"`) + + // Sub-paths intentionally referenced as anti-pattern guidance. + antiPatternAllowlist = map[string]bool{ + "dws calendar list": true, + "dws minutes detail": true, + "dws minutes info": true, + "dws minutes summary": true, + "dws minutes transcription": true, + "dws report inbox": true, + "dws skill add": true, + "dws skill find": true, + } + + // Line markers indicating an anti-pattern context (whole line skipped). + antiPatternLineMarkers = []string{ + "[禁止]", "【禁止】", "禁止使用", "禁止编造", "❌", + "错误写法", "高频错误", "反例", "不存在", "不应该", + "不要写", "不要用", "反模式", "错例", + "该命令不存在", "不是合法", "unknown flag", "LLM 高频幻觉", + "臆造", "虚构", "编造", "缺少子命令", "缺少 scope", + "不识别", "不认识", "不存在此", "不是顶层", "应该是", "应为", + "参数名是", "参数错", "应该用", "废弃", "已下线", + } + + // Per-file line ranges considered intentional anti-pattern blocks. + antiPatternRanges = map[string][][2]int{ + "skills/multi/dingtalk-minutes/references/minutes.md": { + {40, 130}, {1280, 1320}, {1700, 1900}, + }, + } + + // Verbs that imply writes; auto-append --dry-run. + writeVerbs = map[string]bool{ + "create": true, "update": true, "delete": true, "send": true, + "recall": true, "approve": true, "reject": true, "revoke": true, + "commit": true, "mkdir": true, "add": true, "remove": true, + "set": true, "invite": true, "transfer": true, "quit": true, + "rename": true, "move": true, "copy": true, "append": true, + "insert": true, "merge": true, "unmerge": true, "write": true, + "replace": true, "mute": true, "cancel": true, "install": true, + "publish": true, "forward": true, "reply": true, + } +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, here, _, _ := runtime.Caller(0) + return filepath.Clean(filepath.Join(filepath.Dir(here), "..", "..")) +} + +func dwsBinary(t *testing.T) string { + t.Helper() + p, err := exec.LookPath("dws") + if err != nil { + t.Skipf("dws binary not on PATH; build it (go build -o /tmp/dws ./cmd && PATH=/tmp:$PATH) to enable this layer-B test") + } + return p +} + +type cmdRef struct { + File string + Line int + Cmd string +} + +func extract(root string) ([]cmdRef, error) { + dir := filepath.Join(root, "skills") + var files []string + for _, pat := range []string{ + dir + "/mono/SKILL.md", + dir + "/mono/references/products/*.md", + dir + "/multi/*/SKILL.md", + dir + "/multi/*/references/*.md", + } { + m, err := filepath.Glob(pat) + if err != nil { + return nil, err + } + files = append(files, m...) + } + + var refs []cmdRef + for _, f := range files { + out, err := exec.Command("cat", f).Output() + if err != nil { + return nil, err + } + text := string(out) + lineNo := 0 + scanner := bufio.NewScanner(strings.NewReader(text)) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024*4) + for scanner.Scan() { + lineNo++ + line := scanner.Text() + if isAntiLine(line) || inAntiRange(root, f, lineNo) { + continue + } + for _, m := range backtickRe.FindAllStringSubmatch(line, -1) { + cmd := strings.TrimSpace(m[1]) + if shouldSkip(cmd) { + continue + } + refs = append(refs, cmdRef{File: f, Line: lineNo, Cmd: cmd}) + } + } + } + return refs, nil +} + +func isAntiLine(line string) bool { + for _, marker := range antiPatternLineMarkers { + if strings.Contains(line, marker) { + return true + } + } + return false +} + +func inAntiRange(root, file string, ln int) bool { + rel, _ := filepath.Rel(root, file) + ranges, ok := antiPatternRanges[rel] + if !ok { + return false + } + for _, r := range ranges { + if ln >= r[0] && ln <= r[1] { + return true + } + } + return false +} + +func shouldSkip(cmd string) bool { + if strings.Contains(cmd, "[flags]") || strings.Contains(cmd, "[command]") || strings.Contains(cmd, "[--") { + return true + } + if strings.Contains(cmd, "|") || strings.Contains(cmd, "$(") || strings.Contains(cmd, " & ") { + return true + } + if strings.Contains(cmd, "...") || strings.Contains(cmd, " > ") { + return true + } + // Sub-path must not contain placeholder syntax. + subPart := cmd + if idx := strings.Index(cmd, " --"); idx >= 0 { + subPart = cmd[:idx] + } + // Drop quoted strings before scanning + subPart = quotedRe.ReplaceAllString(subPart, "__Q__") + for _, t := range strings.Fields(subPart) { + if strings.HasPrefix(t, "<") || strings.Contains(t, "/") || strings.Contains(t, "*") { + return true + } + } + // Placeholders with spaces inside angle brackets confuse word-based parsing + for _, m := range regexp.MustCompile(`<[^>]*>`).FindAllString(cmd, -1) { + if strings.Contains(m, " ") { + return true + } + } + return false +} + +func subPath(cmd string) string { + cmd2 := quotedRe.ReplaceAllString(cmd, "__Q__") + var tokens []string + for _, t := range strings.Fields(cmd2) { + if strings.HasPrefix(t, "-") { + break + } + tokens = append(tokens, t) + } + return strings.Join(tokens, " ") +} + +func isWrite(sub string) bool { + tokens := strings.Fields(sub) + for i, t := range tokens { + if i == 0 { + continue + } + if writeVerbs[t] { + return true + } + // Compound names like "send-by-bot", "recall-by-bot" etc. + for w := range writeVerbs { + if strings.HasPrefix(t, w+"-") { + return true + } + } + } + return false +} + +// substitute replaces well-known placeholders with safe dummy values. +func substitute(cmd string) string { + subst := map[string]string{ + "": "test", "": "test", "<群名>": "test", + "<姓名>": "wukong01", "<标题>": "_test", "<内容>": "_test", + "": "2026-05-21", "": "2026-05-21", + "": "2026-05-14T00:00:00+08:00", + "": "2026-05-21T23:59:59+08:00", + "": "2026-05-21T10:00:00+08:00", + "": "userDummy", "": "cidDummy==", + "": "cidDummy==", + "": "msgDummy==", "": "Dummy", + "": "rcDummy", "": "rcDummy", + "": "tk", "": "1", "": "1", + "": "PROC_d", "": "INST_d", + "": "0123456789abcdef0123456789abcdef", + "": "d", "": "sp", "": "ws", + "": "n", "": "Sheet1", "": "b", + "": "t", "": "f", "": "1", + "": "1", "": "k", "": "x@y.z", + "": "x@y.z", "": "x@y.z", + "": "primary", "": "e", + "": "rows", "": "0", "": "1", + "": "OWNER", "": "mine", "": "myWikiSpace", + "": "xlsx", "": "A1:B2", + "": "n", "": "Sheet1", "": "b", + "": "t", "": "f", "": "n", + "": "1", "": "r", "": "TASK_d", + "": "/tmp/test.txt", "": "/tmp/test.txt", + } + for k, v := range subst { + cmd = strings.ReplaceAll(cmd, k, v) + } + // Catch-all for any remaining + return regexp.MustCompile(`<[^>]+>`).ReplaceAllString(cmd, "dummy") +} + +func TestSkillCommandsMockDispatch(t *testing.T) { + root := repoRoot(t) + dws := dwsBinary(t) + + refs, err := extract(root) + if err != nil { + t.Fatalf("extract: %v", err) + } + + // Dedupe by exact command text. + seen := map[string]bool{} + var unique []cmdRef + for _, r := range refs { + if antiPatternAllowlist[subPath(r.Cmd)] || seen[r.Cmd] { + continue + } + seen[r.Cmd] = true + unique = append(unique, r) + } + t.Logf("Extracted %d refs, %d unique runnable commands", len(refs), len(unique)) + + type failure struct { + ref cmdRef + actual string + reason string + } + var fails []failure + var mu sync.Mutex + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 8) // parallelism cap + + for _, r := range unique { + r := r + wg.Add(1) + semaphore <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-semaphore }() + actual := substitute(r.Cmd) + if !strings.Contains(actual, " --mock") { + actual += " --mock" + } + if !strings.Contains(actual, " --format") { + actual += " --format json" + } + if isWrite(subPath(r.Cmd)) && !strings.Contains(actual, " --dry-run") { + actual += " --dry-run" + } + argv := strings.Fields(actual) + // shlex-light: rejoin quoted segments + argv = rejoinQuoted(actual) + out, _ := exec.Command(dws, argv[1:]...).CombinedOutput() + s := string(out) + if strings.Contains(s, "unknown flag") || strings.Contains(s, "unknown command") { + mu.Lock() + fails = append(fails, failure{ref: r, actual: actual, reason: firstLine(s, 200)}) + mu.Unlock() + return + } + // Parse JSON; must have either _mock=true or success-marker. + var j map[string]any + if err := json.Unmarshal(out, &j); err != nil { + // Non-JSON output is fine for help-like commands; only flag a fail if + // stderr/exit-code indicates dispatch error (we already caught unknown above). + return + } + if mock, ok := j["_mock"].(bool); ok && mock { + return // PASS: mocked dispatch worked + } + // Some commands (helper-overrides) don't proxy through mock; accept success markers too. + if v, ok := j["success"]; ok && (v == true || v == "true") { + return + } + if errBlock, ok := j["error"].(map[string]any); ok { + msg, _ := errBlock["message"].(string) + if strings.Contains(msg, "unknown flag") || strings.Contains(msg, "unknown command") { + mu.Lock() + fails = append(fails, failure{ref: r, actual: actual, reason: msg}) + mu.Unlock() + } + } + }() + } + wg.Wait() + + if len(fails) == 0 { + t.Logf("✓ All %d unique skill commands dispatch cleanly under --mock", len(unique)) + return + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d skill commands that fail at cobra/envelope dispatch under --mock:\n\n", len(fails))) + for _, f := range fails { + rel, _ := filepath.Rel(root, f.ref.File) + sb.WriteString(fmt.Sprintf(" ❌ %s:%d\n", rel, f.ref.Line)) + sb.WriteString(fmt.Sprintf(" cmd: %s\n", f.ref.Cmd)) + sb.WriteString(fmt.Sprintf(" actual: %s\n", f.actual)) + sb.WriteString(fmt.Sprintf(" reason: %s\n\n", f.reason)) + } + t.Fatal(sb.String()) +} + +func firstLine(s string, max int) string { + if i := strings.Index(s, "\n"); i >= 0 { + s = s[:i] + } + if len(s) > max { + s = s[:max] + } + return s +} + +// rejoinQuoted is a tiny shell-tokenizer that keeps "double-quoted" runs as one arg. +func rejoinQuoted(s string) []string { + var argv []string + var cur strings.Builder + inQ := false + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '"': + inQ = !inQ + case ' ', '\t': + if inQ { + cur.WriteByte(c) + continue + } + if cur.Len() > 0 { + argv = append(argv, cur.String()) + cur.Reset() + } + default: + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + argv = append(argv, cur.String()) + } + return argv +} diff --git a/test/skill_static/skill_static_test.go b/test/skill_static/skill_static_test.go new file mode 100644 index 00000000..54a02e4c --- /dev/null +++ b/test/skill_static/skill_static_test.go @@ -0,0 +1,344 @@ +//go:build skill_verify +// +build skill_verify + +// Package skill_static_test verifies that every `dws ...` command example +// referenced in skill documentation can actually be dispatched by the +// open-source dws CLI binary (cobra Available Commands check). +// +// Build tag rationale: this test depends on a real `dws` binary being +// reachable via PATH, which the default `go test ./...` doesn't guarantee. +// Run explicitly with: `go test -tags skill_verify ./test/skill_static/...` +// +// Layer A of the skill verification matrix: +// 1. Every backtick `dws ...` command in skills/**/*.md is parsed. +// 2. For each, the sub-command path (tokens before first --flag) is +// checked against `dws --help`'s "Available Commands" list. +// 3. Every flag used must appear in the leaf command's help text. +// +// The test deliberately tolerates a small whitelist of intentional +// anti-pattern references (e.g. "dws minutes detail" appearing in the +// 「高频错误命令对照表」 column of a 错例 → 正解 table). Anything else +// must dispatch cleanly. +package skill_static_test + +import ( + "bufio" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "testing" +) + +var ( + backtickCmd = regexp.MustCompile("`(dws\\s+[^`]+?)`") + + // Sub-paths intentionally referenced as anti-pattern examples + // (kept in docs to guide LLMs away from common hallucinations). + antiPatternAllowlist = map[string]bool{ + "dws calendar list": true, // calendar.md: "CLI doesn't have this; do not invent it" + "dws minutes detail": true, // minutes.md anti-pattern table + "dws minutes info": true, // (top-level, not under `get`) + "dws minutes summary": true, + "dws minutes transcription": true, + "dws report inbox": true, // explicit "禁止编造" warnings + "dws skill add": true, // backward-compat stub, exists but not in Available Commands + "dws skill find": true, // backward-compat stub + } + + // Argument-like tokens the parser should not mistake for sub-commands. + dateLikeRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`) +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, here, _, _ := runtime.Caller(0) + // test/skill_static/skill_static_test.go → repo root + return filepath.Clean(filepath.Join(filepath.Dir(here), "..", "..")) +} + +func dwsBinary(t *testing.T) string { + t.Helper() + p, err := exec.LookPath("dws") + if err != nil { + t.Skipf("dws binary not on PATH; install dws (or run `go build -o /tmp/dws ./cmd` and PATH=/tmp:$PATH) to enable this test") + } + return p +} + +type cmdRef struct { + File string + Line int + Cmd string +} + +// extractCommands scans skills/**/*.md for every backtick `dws ...` command. +func extractCommands(root string) ([]cmdRef, error) { + skillsDir := filepath.Join(root, "skills") + var refs []cmdRef + files, err := filepath.Glob(skillsDir + "/mono/SKILL.md") + if err != nil { + return nil, err + } + var more []string + if more, err = filepath.Glob(skillsDir + "/mono/references/products/*.md"); err == nil { + files = append(files, more...) + } + if more, err = filepath.Glob(skillsDir + "/mono/references/*.md"); err == nil { + files = append(files, more...) + } + if more, err = filepath.Glob(skillsDir + "/multi/*/SKILL.md"); err == nil { + files = append(files, more...) + } + if more, err = filepath.Glob(skillsDir + "/multi/*/references/*.md"); err == nil { + files = append(files, more...) + } + for _, f := range files { + data, err := readFile(f) + if err != nil { + return nil, err + } + lineNo := 0 + scanner := bufio.NewScanner(strings.NewReader(data)) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024*4) + for scanner.Scan() { + lineNo++ + matches := backtickCmd.FindAllStringSubmatch(scanner.Text(), -1) + for _, m := range matches { + cmd := strings.TrimSpace(m[1]) + if shouldSkip(cmd) { + continue + } + refs = append(refs, cmdRef{File: f, Line: lineNo, Cmd: cmd}) + } + } + } + return refs, nil +} + +func readFile(p string) (string, error) { + b, err := exec.Command("cat", p).Output() + if err != nil { + return "", err + } + return string(b), nil +} + +// anglePlaceholderRe matches any <...> placeholder. We check raw cmd because +// placeholders with spaces (e.g. ) confuse word-based +// parsing — easier to reject upfront. +var anglePlaceholderRe = regexp.MustCompile(`<[^>]*>`) + +func shouldSkip(cmd string) bool { + if strings.Contains(cmd, "[flags]") || strings.Contains(cmd, "[command]") { + return true + } + if strings.Contains(cmd, "|") || strings.Contains(cmd, "$(") || strings.Contains(cmd, " & ") { + return true + } + if strings.Contains(cmd, "...") || strings.Contains(cmd, " > ") { + return true + } + // Markdown optional-flag notation like `[--limit <1-20>]` or `[--yes]` + // confuses word-based parsing. Treat any "[--" as a templated example. + if strings.Contains(cmd, "[--") { + return true + } + // Replace placeholders with a safe token, then check sub-path portion + // for any remaining noise. If the placeholder contains spaces it would + // already have polluted word-based parsing — easier to skip entirely. + if anglePlaceholderRe.MatchString(cmd) { + // Allow only commands where placeholder is entirely inside flag values + // (after first " --"). Sub-path tokens (before first --flag) must be clean. + idx := strings.Index(cmd, " --") + if idx < 0 { + return true // no --flag, but has <>, so whole cmd is templated + } + if anglePlaceholderRe.MatchString(cmd[:idx]) { + return true + } + // Placeholder inside flag values is fine — but if it spans spaces, + // our word-based parser would break. Detect spaces inside any <...>. + for _, m := range anglePlaceholderRe.FindAllString(cmd, -1) { + if strings.Contains(m, " ") { + return true + } + } + } + // Reject "/" or "*" within sub-path tokens. + subPart := cmd + if idx := strings.Index(cmd, " --"); idx >= 0 { + subPart = cmd[:idx] + } + for _, t := range strings.Fields(subPart) { + if strings.Contains(t, "/") || strings.Contains(t, "*") { + return true + } + } + return false +} + +// quotedRe matches double-quoted substrings (possibly with spaces inside). +var quotedRe = regexp.MustCompile(`"[^"]*"`) + +// parseSubPath returns (sub-path, leaf, flags). sub-path is tokens up to but +// not including the first --flag and any clearly-non-subcommand token. +func parseSubPath(cmd string) (subPath, leaf string, flags []string) { + // Collapse "quoted strings" to a single placeholder so word-split works. + cmd = quotedRe.ReplaceAllString(cmd, "__QUOTED__") + tokens := strings.Fields(cmd) + var subTokens []string + for i := 0; i < len(tokens); i++ { + t := tokens[i] + if strings.HasPrefix(t, "--") || (strings.HasPrefix(t, "-") && len(t) > 1 && !strings.ContainsAny(string(t[1]), "0123456789")) { + // flag, take name before '=' + flags = append(flags, strings.SplitN(t, "=", 2)[0]) + // skip its value if present + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + } + continue + } + if len(subTokens) == 0 && t != "dws" { + break // doesn't start with dws → not a real cmd + } + // Stop sub-path on noise tokens + if dateLikeRe.MatchString(t) || strings.Contains(t, "/") || strings.Contains(t, "*") { + break + } + subTokens = append(subTokens, t) + } + if len(subTokens) < 2 { + return "", "", flags + } + subPath = strings.Join(subTokens, " ") + leaf = subTokens[len(subTokens)-1] + return subPath, leaf, flags +} + +// helpCache memoizes `dws --help` outputs. +type helpCache struct { + mu sync.Mutex + dws string + m map[string]string +} + +func newHelpCache(dws string) *helpCache { + return &helpCache{dws: dws, m: map[string]string{}} +} + +func (c *helpCache) get(sub string) string { + c.mu.Lock() + defer c.mu.Unlock() + if v, ok := c.m[sub]; ok { + return v + } + args := append(strings.Fields(sub)[1:], "--help") + out, _ := exec.Command(c.dws, args...).CombinedOutput() + c.m[sub] = string(out) + return string(out) +} + +var availableRe = regexp.MustCompile(`(?s)Available Commands:\n((?:\s+\S.*\n)+)`) + +func parseAvailable(helpText string) []string { + m := availableRe.FindStringSubmatch(helpText) + if m == nil { + return nil + } + var cmds []string + for _, line := range strings.Split(m[1], "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && !strings.HasPrefix(fields[0], "-") { + cmds = append(cmds, fields[0]) + } + } + return cmds +} + +func TestSkillCommandsDispatch(t *testing.T) { + root := repoRoot(t) + dws := dwsBinary(t) + cache := newHelpCache(dws) + + refs, err := extractCommands(root) + if err != nil { + t.Fatalf("extract: %v", err) + } + t.Logf("Extracted %d command references from skill docs", len(refs)) + + type failure struct { + ref cmdRef + reason string + } + var fails []failure + seen := map[string]bool{} // dedupe identical sub-paths + + for _, r := range refs { + sub, _, _ := parseSubPath(r.Cmd) + if sub == "" { + continue + } + if antiPatternAllowlist[sub] { + continue + } + if seen[sub+"|"+r.File] { + continue + } + seen[sub+"|"+r.File] = true + + // Compute parent + leaf + tokens := strings.Fields(sub) + if len(tokens) < 2 { + continue + } + parent := strings.Join(tokens[:len(tokens)-1], " ") + leaf := tokens[len(tokens)-1] + + // Strategy: directly check sub's own help; if its Usage line starts + // with the sub-path, the sub-command is registered. Otherwise cobra + // fell back to a parent and the sub doesn't exist. + subHelp := cache.get(sub) + expectedUsage := "Usage:\n " + sub + if strings.Contains(subHelp, expectedUsage) { + continue // sub-path exists, all good + } + + // Sub may be a leaf with subcommands list — also valid (e.g. dws sheet) + // In that case Usage line is "Usage:\n [flags]\n [command]" + if strings.Contains(subHelp, "Usage:\n "+sub+" [flags]\n "+sub+" [command]") { + continue + } + + // Otherwise verify by looking at parent's Available Commands + parentHelp := cache.get(parent) + available := parseAvailable(parentHelp) + found := false + for _, c := range available { + if c == leaf { + found = true + break + } + } + if !found { + fails = append(fails, failure{ref: r, reason: fmt.Sprintf("`%s` not in Available Commands of `%s`: %v", leaf, parent, available)}) + } + } + + if len(fails) == 0 { + t.Logf("✓ All extracted commands dispatch cleanly (sub-paths registered, anti-patterns whitelisted)") + return + } + + // Format failure report + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d skill-doc commands that don't dispatch in open-source dws v1.0.30:\n\n", len(fails))) + for _, f := range fails { + rel, _ := filepath.Rel(root, f.ref.File) + sb.WriteString(fmt.Sprintf(" ❌ %s:%d\n cmd: %s\n reason: %s\n\n", rel, f.ref.Line, f.ref.Cmd, f.reason)) + } + t.Fatal(sb.String()) +}