chore: initialize

This commit is contained in:
wecom-bot
2026-03-30 00:17:52 +08:00
commit 0e6f4c93c0
70 changed files with 8536 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/target
node_modules
.codebuddy
/scripts
# Platform binary directories (populated during build)
packages/darwin-arm64/bin/
packages/darwin-x64/bin/
packages/linux-x64/bin/
packages/win32-x64/bin/
Generated
+2897
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "wecom-cli"
version = "0.1.1"
edition = "2024"
[[bin]]
name = "wecom-cli"
path = "src/main.rs"
[dependencies]
tokio = { version = "1.50.0", features = ["full"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
reqwest = { version = "0.13.2", default-features = false, features = ["json", "multipart", "stream", "rustls"] }
dotenvy = "0.15.7"
dirs = "6.0.0"
base64 = "0.22"
mime = "0.3"
mime_guess = "2"
infer = "0.19"
rand = "0.9"
hex = "0.4"
sha2 = "0.10"
aes-gcm = "0.10"
keyring = { version = "3", features = ["apple-native", "linux-native"] }
sanitize-filename = "0.6.0"
tempfile = "3.27.0"
cliclack = "0.5"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WeCom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+331
View File
@@ -0,0 +1,331 @@
# wecom-cli
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/rust-%3E%3D1.75-orange.svg)](https://www.rust-lang.org/)
> 💬 扫码加入企业微信交流群:
>
> <img src="https://wwcdn.weixin.qq.com/node/wework/images/202603241759.3fb01c32cc.png" alt="扫码入群交流" width="200" />
企业微信命令行工具 — 让人类和 AI Agent 都能在终端中操作企业微信。覆盖通讯录、待办、会议、消息、日程、文档、智能表格等核心业务域,提供 7 大品类及 12 个 AI Agent [Skills](https://github.com/WecomTeam/wecom-cli/tree/main/skills)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [命令](#命令参考) · [品类一览](#品类与能力一览)
## 为什么选 wecom-cli
- **为 AI Agent 所设计** — 开箱即用的 [Skills](https://github.com/WecomTeam/wecom-cli/tree/main/skills), 适配主流 AI 工具,Agent 可直接操作企业微信,无需额外适配
- **覆盖用户核心需求** — 7 大业务品类、12 个 AI Agent [Skills](https://github.com/WecomTeam/wecom-cli/tree/main/skills),覆盖通讯录、待办、会议、消息、日程、文档与智能表格
- **快速上手** — `init` 配置凭证,直接调用品类工具,从安装到第一次 API 调用只需两步
## 功能
| 类别 | 能力 |
| ------------ | ----------------------------------------------------------------------------- |
| 👤 通讯录 | 获取可见范围成员列表、按姓名/别名搜索 |
| ✅ 待办 | 创建、查询列表、查询详情、更新、删除待办,变更用户处理状态 |
| 🎥 会议 | 创建预约会议、取消会议、更新受邀成员、查询会议列表、获取会议详情 |
| 💬 消息 | 会话列表查询、消息记录拉取(文本/图片/文件/语音/视频)、多媒体下载、发送文本 |
| 📅 日程 | 日程 CRUD、参与人管理、多成员闲忙查询 |
| 📄 文档 | 文档创建/读取/编辑 |
| 📊 智能表格 | 智能表格创建、子表与字段管理、表格记录增删改查 |
## 安装与快速开始
### 环境要求
- Node.js`npm`/`npx`
- 企业微信机器人的 Bot ID 和 Secret
### 安装
```bash
# 安装 CLI
npm install -g @wecom/cli
# 安装 CLI SKILL(必需)
npx skills add WeComTeam/wecom-cli -y -g
```
### 快速开始
```bash
# 1. 配置企业微信机器人凭证(交互式,仅需一次)
wecom-cli init
# 2. 调用工具
wecom-cli contact get_userlist '{}'
```
## Agent Skills
| Skill | 品类 | 说明 |
| ----- | ---- | ---- |
| `wecomcli-lookup-contact` | contact | 通讯录成员查询,按姓名/别名搜索 |
| `wecomcli-get-todo-list` | todo | 待办列表查询,按时间过滤和分页 |
| `wecomcli-get-todo-detail` | todo | 待办详情批量查询 |
| `wecomcli-edit-todo` | todo | 待办创建、更新、删除、状态变更 |
| `wecomcli-create-meeting` | meeting | 创建预约会议 |
| `wecomcli-edit-meeting` | meeting | 取消会议、更新受邀成员 |
| `wecomcli-get-meeting` | meeting | 查询会议列表和详情 |
| `wecomcli-get-msg` | msg | 会话列表、消息记录、媒体下载、文本发送 |
| `wecomcli-manage-schedule` | schedule | 日程 CRUD、参与人管理、闲忙查询 |
| `wecomcli-manage-doc` | doc | 文档创建/读取/编辑 |
| `wecomcli-manage-smartsheet-schema` | smartsheet | 智能表格子表与字段管理 |
| `wecomcli-manage-smartsheet-data` | smartsheet | 智能表格记录增删改查 |
## 命令参考
### `--help`
列出所有支持的命令和品类。
```bash
wecom-cli --help
```
输出示例:
```
Usage: wecom-cli <COMMAND>
Commands:
init Documentation for init
contact 通讯录 — 成员查询和搜索
doc 文档 — 文档/智能表格创建和管理
meeting 会议 — 创建/管理/查询视频会议
msg 消息 — 聊天列表、发送/接收消息、媒体下载
schedule 日程 — 日程增删改查和可用性查询
todo 待办事项 — 创建/查询/编辑待办项
Options:
-h, --help Print help
-V, --version Print version
```
### `init`
交互式配置企业微信机器人凭证,加密存储到本地。仅需执行一次。
```bash
wecom-cli init
```
| 参数 | 必填 | 说明 |
| ---------- | ---- | -------------- |
| `--bot-id` | 可选 | 企业微信机器人 Bot ID |
凭证存储位置:`~/.config/wecom/bot.enc`
### 品类调用
每个品类作为独立子命令使用。不传方法名时列出该品类下所有可用工具,传方法名时调用指定工具。
```bash
# 列出品类下的所有工具
wecom-cli <category>
# 调用品类下的指定工具
wecom-cli <category> <method> [json_args]
```
示例:
```bash
# 列出通讯录品类下的工具
wecom-cli contact
# 列出待办品类下的工具
wecom-cli todo
# 调用工具(传 JSON 参数)
wecom-cli contact get_userlist '{}'
# 调用工具(无参数)
wecom-cli contact get_userlist
```
## 品类与能力一览
### contact — 通讯录
| 工具 | 说明 |
|------|------|
| `get_userlist` | 获取当前用户可见范围内的通讯录成员(userid、姓名、别名) |
```bash
# 获取全量通讯录成员
wecom-cli contact get_userlist '{}'
```
### todo — 待办
| 工具 | 说明 |
|------|------|
| `get_todo_list` | 查询待办列表,支持按时间过滤和分页 |
| `get_todo_detail` | 根据待办 ID 批量查询完整详情 |
| `create_todo` | 创建待办,可指定内容、分派人、提醒时间 |
| `update_todo` | 更新待办内容、状态、分派人或提醒时间 |
| `delete_todo` | 删除待办(不可撤销) |
| `change_todo_user_status` | 变更当前用户在待办中的状态 |
```bash
# 查询待办列表
wecom-cli todo get_todo_list '{}'
# 创建待办
wecom-cli todo create_todo '{"content": "完成Q2规划文档", "remind_time": "2026-06-01 09:00:00"}'
# 批量查询待办详情
wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID_1", "TODO_ID_2"]}'
# 标记待办完成
wecom-cli todo update_todo '{"todo_id": "TODO_ID", "todo_status": 0}'
# 删除待办
wecom-cli todo delete_todo '{"todo_id": "TODO_ID"}'
```
### meeting — 会议
| 工具 | 说明 |
|------|------|
| `create_meeting` | 创建预约会议,支持设置参数、邀请参与人、安全设置 |
| `cancel_meeting` | 取消指定的预约会议 |
| `set_invite_meeting_members` | 更新会议受邀成员(全量覆盖) |
| `list_user_meetings` | 查询用户在时间范围内的会议列表(当日前后 30 天) |
| `get_meeting_info` | 获取会议完整详情 |
```bash
# 查询本周会议
wecom-cli meeting list_user_meetings '{"begin_datetime": "2026-03-23 00:00", "end_datetime": "2026-03-29 23:59", "limit": 100}'
# 创建会议
wecom-cli meeting create_meeting '{"title": "技术方案评审", "meeting_start_datetime": "2026-03-30 15:00", "meeting_duration": 3600, "invitees": {"userid": ["zhangsan", "lisi"]}}'
# 获取会议详情
wecom-cli meeting get_meeting_info '{"meetingid": "MEETING_ID"}'
# 取消会议
wecom-cli meeting cancel_meeting '{"meetingid": "MEETING_ID"}'
```
### msg — 消息
| 工具 | 说明 |
|------|------|
| `get_msg_chat_list` | 按时间范围查询有消息的会话列表 |
| `get_message` | 拉取会话消息记录(支持文本/图片/文件/语音/视频) |
| `get_msg_media` | 下载消息中的多媒体文件到本地 |
| `send_message` | 向单聊或群聊发送文本消息 |
```bash
# 获取最近一周会话列表
wecom-cli msg get_msg_chat_list '{"begin_time": "2026-03-22 00:00:00", "end_time": "2026-03-29 23:59:59"}'
# 拉取聊天记录
wecom-cli msg get_message '{"chat_type": 1, "chatid": "zhangsan", "begin_time": "2026-03-29 09:00:00", "end_time": "2026-03-29 18:00:00"}'
# 发送文本消息
wecom-cli msg send_message '{"chat_type": 1, "chatid": "zhangsan", "msgtype": "text", "text": {"content": "hello"}}'
# 下载多媒体文件
wecom-cli msg get_msg_media '{"media_id": "MEDIA_ID"}'
```
### schedule — 日程
| 工具 | 说明 |
|------|------|
| `get_schedule_list_by_range` | 查询时间范围内的日程 ID 列表(当日前后 30 天) |
| `get_schedule_detail` | 批量获取日程详情(1~50 个) |
| `create_schedule` | 创建日程,支持设置提醒、参与人 |
| `update_schedule` | 修改日程(只传需修改的字段) |
| `cancel_schedule` | 取消日程 |
| `add_schedule_attendees` | 添加日程参与人 |
| `del_schedule_attendees` | 移除日程参与人 |
| `check_availability` | 查询多成员闲忙状态(1~10 人) |
```bash
# 查询今天的日程
wecom-cli schedule get_schedule_list_by_range '{"start_time": "2026-03-29 00:00:00", "end_time": "2026-03-29 23:59:59"}'
# 获取日程详情
wecom-cli schedule get_schedule_detail '{"schedule_id_list": ["SCHEDULE_ID"]}'
# 创建日程
wecom-cli schedule create_schedule '{"schedule": {"start_time": "2026-03-30 14:00:00", "end_time": "2026-03-30 15:00:00", "summary": "需求评审", "attendees": [{"userid": "zhangsan"}], "reminders": {"is_remind": 1, "remind_before_event_secs": 900, "timezone": 8}}}'
# 查询闲忙
wecom-cli schedule check_availability '{"check_user_list": ["zhangsan", "lisi"], "start_time": "2026-03-30 09:00:00", "end_time": "2026-03-30 18:00:00"}'
```
### doc — 文档
| 工具 | 说明 |
|------|------|
| `create_doc` | 创建文档(doc_type=3 |
| `get_doc_content` | 获取文档内容(Markdown 格式,异步轮询) |
| `edit_doc_content` | 用 Markdown 覆写文档正文 |
```bash
# 创建文档
wecom-cli doc create_doc '{"doc_type": 3, "doc_name": "项目周报"}'
# 读取文档内容(首次调用)
wecom-cli doc get_doc_content '{"docid": "DOC_ID", "type": 2}'
# 读取文档内容(轮询,携带 task_id)
wecom-cli doc get_doc_content '{"docid": "DOC_ID", "type": 2, "task_id": "TASK_ID"}'
# 编辑文档
wecom-cli doc edit_doc_content '{"docid": "DOC_ID", "content": "# 标题\n\n正文内容", "content_type": 1}'
```
### doc — 智能表格
| 工具 | 说明 |
|------|------|
| `create_doc` | 创建智能表格(通过 doc create_docdoc_type=10 |
| `smartsheet_get_sheet` | 查询智能表格的所有子表 |
| `smartsheet_add_sheet` | 添加子表 |
| `smartsheet_update_sheet` | 修改子表标题 |
| `smartsheet_delete_sheet` | 删除子表(不可逆) |
| `smartsheet_get_fields` | 查询子表的字段/列信息 |
| `smartsheet_add_fields` | 添加字段/列 |
| `smartsheet_update_fields` | 更新字段标题 |
| `smartsheet_delete_fields` | 删除字段/列(不可逆) |
| `smartsheet_get_records` | 查询子表全部记录 |
| `smartsheet_add_records` | 添加记录 |
| `smartsheet_update_records` | 更新记录 |
| `smartsheet_delete_records` | 删除记录(不可逆) |
```bash
# 创建智能表格
wecom-cli doc create_doc '{"doc_type": 10, "doc_name": "任务跟踪表"}'
# 查询智能表格子表
wecom-cli doc smartsheet_get_sheet '{"docid": "DOC_ID"}'
# 查询子表字段信息
wecom-cli doc smartsheet_get_fields '{"docid": "DOC_ID", "sheet_id": "SHEET_ID"}'
# 添加子表字段
wecom-cli doc smartsheet_add_fields '{"docid": "DOC_ID", "sheet_id": "SHEET_ID", "fields": [{"field_title": "状态", "field_type": "FIELD_TYPE_SINGLE_SELECT"}]}'
# 查询子表记录
wecom-cli doc smartsheet_get_records '{"docid": "DOC_ID", "sheet_id": "SHEET_ID"}'
# 添加记录
wecom-cli doc smartsheet_add_records '{"docid": "DOC_ID", "sheet_id": "SHEET_ID", "records": [{"values": {"标题": [{"type": "text", "text": "新任务"}]}}]}'
# 更新记录
wecom-cli doc smartsheet_update_records '{"docid": "DOC_ID", "sheet_id": "SHEET_ID", "key_type":"CELL_VALUE_KEY_TYPE_FIELD_TITLE", "records": [{"record_id": "RECORD_ID", "values": {"标题": [{"type": "text", "text": "已更新"}]}}]}'
# 删除记录
wecom-cli doc smartsheet_delete_records '{"docid": "DOC_ID", "sheet_id": "SHEET_ID", "record_ids": ["RECORD_ID"]}'
```
## 许可证
本项目基于 **MIT 许可证** 开源。
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import os from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
/**
* Returns the platform-specific package name based on current OS and CPU architecture.
*/
function getPlatformPackage() {
const platform = os.platform();
const arch = os.arch();
const platformMap = {
'darwin-arm64': '@wecom/cli-darwin-arm64',
'darwin-x64': '@wecom/cli-darwin-x64',
'linux-x64': '@wecom/cli-linux-x64',
'win32-x64': '@wecom/cli-win32-x64',
};
const key = `${platform}-${arch}`;
const pkg = platformMap[key];
if (!pkg) {
console.error(
`Error: unsupported platform ${platform}-${arch}.\n` +
`Supported platforms: ${Object.keys(platformMap).join(', ')}`
);
process.exit(1);
}
return pkg;
}
/**
* Resolves the path to the platform-specific binary.
*/
function getBinaryPath() {
const pkg = getPlatformPackage();
const binaryName = os.platform() === 'win32' ? 'wecom-cli.exe' : 'wecom-cli';
try {
const pkgDir = require.resolve(`${pkg}/package.json`);
return join(pkgDir, '..', 'bin', binaryName);
} catch {
console.error(
`Error: cannot find @wecom/cli binary.\n` +
`Please try reinstalling: npm install @wecom/cli\n\n` +
`If the problem persists, check:\n` +
` 1. Your npm config does not disable optional dependencies (--no-optional)\n` +
` 2. Your platform (${os.platform()}-${os.arch()}) is supported`
);
process.exit(1);
}
}
// Execute the binary, passing through all arguments
const binaryPath = getBinaryPath();
try {
execFileSync(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
env: process.env,
});
} catch (error) {
if (error.status != null) {
process.exit(error.status);
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@wecom/cli",
"version": "0.1.1",
"description": "The official CLI for WeCom",
"keywords": [
"wecom-cli",
"mcp"
],
"homepage": "https://github.com/WecomTeam/wecom-cli#readme",
"bugs": {
"url": "https://github.com/WecomTeam/wecom-cli/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/WecomTeam/wecom-cli.git"
},
"license": "MIT",
"type": "module",
"bin": {
"wecom-cli": "./bin/wecom.js"
},
"files": [
"bin",
"README.md"
],
"optionalDependencies": {
"@wecom/cli-darwin-arm64": "0.1.1",
"@wecom/cli-darwin-x64": "0.1.1",
"@wecom/cli-linux-x64": "0.1.1",
"@wecom/cli-win32-x64": "0.1.1"
},
"packageManager": "pnpm@10.32.1",
"engines": {
"node": ">=18"
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WeCom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# @wecom/cli-darwin-arm64
Platform-specific binary package for **macOS ARM64 (Apple Silicon)**.
## About
This package contains the pre-built `wecom-cli` binary for macOS on Apple Silicon (M1/M2/M3/M4) architecture.
**You should not install this package directly.** It is automatically installed as an optional dependency of [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) when running on a compatible platform.
## Supported Platform
| OS | CPU |
|--------|-------|
| macOS | arm64 |
## Usage
Install the main package instead:
```bash
npm install -g @wecom/cli
```
## License
MIT
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@wecom/cli-darwin-arm64",
"version": "0.1.1",
"description": "The darwin-arm64 binary for @wecom/cli",
"license": "MIT",
"files": [
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WeCom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# @wecom/cli-darwin-x64
Platform-specific binary package for **macOS x64 (Intel)**.
## About
This package contains the pre-built `wecom-cli` binary for macOS on Intel x64 architecture.
**You should not install this package directly.** It is automatically installed as an optional dependency of [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) when running on a compatible platform.
## Supported Platform
| OS | CPU |
|--------|-----|
| macOS | x64 |
## Usage
Install the main package instead:
```bash
npm install -g @wecom/cli
```
## License
MIT
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@wecom/cli-darwin-x64",
"version": "0.1.1",
"description": "The darwin-x64 binary for @wecom/cli",
"license": "MIT",
"files": [
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"os": [
"darwin"
],
"cpu": [
"x64"
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WeCom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# @wecom/cli-linux-x64
Platform-specific binary package for **Linux x64**.
## About
This package contains the pre-built `wecom-cli` binary for Linux on x64 architecture.
**You should not install this package directly.** It is automatically installed as an optional dependency of [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) when running on a compatible platform.
## Supported Platform
| OS | CPU |
|-------|-----|
| Linux | x64 |
## Usage
Install the main package instead:
```bash
npm install -g @wecom/cli
```
## License
MIT
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@wecom/cli-linux-x64",
"version": "0.1.1",
"description": "The linux-x64 binary for @wecom/cli",
"license": "MIT",
"files": [
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"x64"
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WeCom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# @wecom/cli-win32-x64
Platform-specific binary package for **Windows x64**.
## About
This package contains the pre-built `wecom-cli.exe` binary for Windows on x64 architecture.
**You should not install this package directly.** It is automatically installed as an optional dependency of [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) when running on a compatible platform.
## Supported Platform
| OS | CPU |
|---------|-----|
| Windows | x64 |
## Usage
Install the main package instead:
```bash
npm install -g @wecom/cli
```
## License
MIT
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@wecom/cli-win32-x64",
"version": "0.1.1",
"description": "The win32-x64 binary for @wecom/cli",
"license": "MIT",
"files": [
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"os": [
"win32"
],
"cpu": [
"x64"
]
}
+17
View File
@@ -0,0 +1,17 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
packages/darwin-arm64: {}
packages/darwin-x64: {}
packages/linux-x64: {}
packages/win32-x64: {}
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'packages/*'
+169
View File
@@ -0,0 +1,169 @@
---
name: wecomcli-create-meeting
description: 企业微信会议创建技能, 支持创建预约会议. 当用户需要"创建会议", "预约会议", "约会议", "安排会议"时触发.
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli meeting --help"
---
# 企业微信会议创建技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
## 概述
wecomcli-create-meeting 提供企业微信预约会议的创建能力, 支持设置会议参数, 邀请参与人等.
## 命令调用方式
执行指定命令:
```bash
wecom-cli meeting <tool_name> '<json_params>'
```
---
## 命令详细说明
### 创建预约会议 (create_meeting)
创建一个预约会议, 支持设置会议参数配置等.
#### 执行命令
```bash
wecom-cli meeting create_meeting '{"title": "<会议标题>", "meeting_start_datetime": "<会议开始时间>", "meeting_duration": <会议持续时长(秒)>}'
```
#### 入参说明
| 参数 | 类型 | 必填 | 说明 |
| -------------------------- | ------- | ---- | ------------------------------------------------- |
| `title` | string | 是 | 会议标题 |
| `meeting_start_datetime` | string | 是 | 会议开始时间, 格式:`YYYY-MM-DD HH:mm` |
| `meeting_duration` | integer | 是 | 会议持续时长 (秒), 例如 3600 = 1 小时 |
| `description` | string | 否 | 会议描述 |
| `location` | string | 否 | 会议地点 |
| `invitees` | object | 是 | 被邀请人, 格式:`{"userid": ["lisi", "wangwu"]}` |
| `settings` | object | 否 | 会议设置 (详见下方) |
> 被邀请人 userid 通过 `wecomcli-lookup-contact` 技能获取
**settings 字段:**
| 参数 | 类型 | 说明 |
| --------------------------- | ------- | --------------------------------------------- |
| `password` | string | 会议密码 |
| `enable_waiting_room` | boolean | 是否启用等候室 |
| `allow_enter_before_host` | boolean | 是否允许成员在主持人进入前加入 |
| `enable_enter_mute` | integer | 入会时静音设置 (枚举: 0: 关闭, 1: 开启) |
| `allow_external_user` | boolean | 是否允许外部用户入会 |
| `enable_screen_watermark` | boolean | 是否开启屏幕水印 |
| `remind_scope` | integer | 提醒范围 (1: 不提醒, 2: 仅提醒主持人, 3: 提醒所有成员, 4: 指定部分人响铃, 默认仅提醒主持人) |
| `ring_users` | object | 响铃用户, 格式:`{"userid": ["lisi"]}` |
> 响铃用户 userid 通过 `wecomcli-lookup-contact` 技能获取
#### 返回参数
```json
{
"errcode": 0,
"errmsg": "ok",
"meetingid": "会议ID字符串",
"meeting_code": "会议号码字符串",
"meeting_link": "会议链接URL",
"excess_users": ["无效会议账号的userid"]
}
```
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------ |
| `meetingid` | string | 会议 ID |
| `meeting_code` | string | 会议号码, 向用户展示时需在回复**开头**单独一行纯文字展示, 格式 `#会议号: xxx-xxx-xxx` (每3位用 `-` 分隔) |
| `meeting_link` | string | 会议链接 |
| `excess_users` | array | 参会人中包含无效会议账号的 userid, 仅在购买会议专业版企业由于部分参会人无有效会议账号时返回 |
---
## 典型工作流
### 工作流 1: 最简创建 (无邀请人)
**用户意图**: "帮我约一个明天下午3点的会议, 主题是周例会, 时长1小时"
**步骤:**
1. **解析用户意图**: 时间 + 主题已有, 邀请人未提及则默认留空, 直接创建.
2. **调用创建命令**:
```bash
wecom-cli meeting create_meeting '{"title": "周例会", "meeting_start_datetime": "2026-03-18 15:00", "meeting_duration": 3600}'
```
3. **展示结果**:
#会议号: <会议号>
```
✅ 会议创建成功!
📅 <会议标题>
🕐 时间: <开始时间>, 时长 <时长>
🔗 会议链接: <会议链接>
```
### 工作流 2: 带邀请人 + 地点 + 描述
**用户意图**: "帮我约一个明天下午3点的会议, 主题是技术方案评审, 邀请张三和李四, 地点在3楼会议室, 时长1小时"
**步骤:**
1. **解析用户意图**: 有邀请人, 需先查询通讯录获取 userid.
2. **通讯录查询**: 调用 `wecomcli-lookup-contact` 技能获取通讯录成员, 按姓名筛选出参与者的 userid.
```bash
wecom-cli contact get_userlist '{}'
```
在返回的 `userlist` 中筛选 `name` 包含 "张三" 和 "李四" 的成员, 获取其 `userid`.
3. **信息已充分, 直接调用创建命令** (禁止暴露内部 ID):
```bash
wecom-cli meeting create_meeting '{"title": "技术方案评审", "meeting_start_datetime": "2026-03-18 15:00", "meeting_duration": 3600, "location": "3楼会议室", "invitees": {"userid": ["zhangsan", "lisi"]}}'
```
4. **展示结果**:
#会议号: <会议号>
```
✅ 会议创建成功!
📅 <会议标题>
🕐 时间: <开始时间>, 时长 <时长>
👥 参与人: <参与者姓名列表>
🔗 会议链接: <会议链接>
```
---
## 复杂场景样例
按场景按需加载, 避免一次性引入过多无关示例:
| 文件 | 适用场景 |
| ---- | -------- |
| [references/example-security.md](references/example-security.md) | 会议密码, 等候室, 外部用户限制 |
| [references/example-reminder.md](references/example-reminder.md) | 响铃提醒, 指定部分人响铃 |
| [references/example-full.md](references/example-full.md) | 全参数综合场景 (含静音, 屏幕水印, 等候室等设置) |
---
## 注意事项
- **信息追问**: 缺少时间或主题时, 简洁追问用户; 未提及邀请人则默认留空
- **通讯录查询**: 涉及参与人时, 需先通过 `wecomcli-lookup-contact` 技能的 `get_userlist` 接口获取全量通讯录成员, 再按姓名/别名本地筛选匹配出对应的 `userid`. 该接口无入参, 返回当前用户可见范围内的成员列表 (含 `userid`, `name`, `alias`)
- **直接创建**: 时间 + 主题已知即可直接创建, 邀请人有则带上, 无则留空; 无论信息是一次性提供还是上下文可推断, 非必要则均不请求确认, 直接创建即可
- **时间格式**: 统一使用 `YYYY-MM-DD HH:mm` 格式
@@ -0,0 +1,30 @@
# 创建会议 - 全参数综合场景示例
## 场景 1: 高规格会议 (全参数)
**用户意图**: "帮我创建一个高规格的季度战略会议: 下周一上午9点, 时长4小时, 邀请全团队, 设置密码, 开启等候室, 开启屏幕水印, 全员静音"
```json
{
"title": "Q2季度战略规划会",
"meeting_start_datetime": "2026-03-23 09:00",
"meeting_duration": 14400,
"description": "Q2季度战略规划, 请各部门负责人提前准备汇报材料",
"location": "总部大会议室",
"invitees": {
"userid": ["zhangsan", "lisi", "wangwu", "zhaoliu", "sunqi"]
},
"settings": {
"password": "2026",
"enable_waiting_room": true,
"allow_enter_before_host": false,
"enable_enter_mute": 1,
"allow_external_user": false,
"enable_screen_watermark": true,
"remind_scope": 3,
"ring_users": {
"userid": ["zhangsan", "lisi", "wangwu", "zhaoliu", "sunqi"]
}
}
}
```
@@ -0,0 +1,46 @@
# 创建会议 - 响铃提醒场景示例
## 场景 2: 仅提醒主持人 + 提前提醒时间
**用户意图**: "帮我创建一个会议, 只提醒主持人, 并在会议开始前15分钟和1小时各提醒一次"
```json
{
"title": "项目启动会",
"meeting_start_datetime": "2026-03-21 10:00",
"meeting_duration": 3600,
"invitees": {
"userid": ["zhangsan", "lisi"]
},
"settings": {
"remind_scope": 2,
"ring_users": {
"userid": ["zhangsan"]
}
}
}
```
---
## 场景 3: 指定部分人响铃 (remind_scope=4)
**用户意图**: "帮我创建一个会议, 只响铃提醒张三和李四, 其他人不提醒"
```json
{
"title": "紧急故障复盘",
"meeting_start_datetime": "2026-03-18 20:00",
"meeting_duration": 3600,
"invitees": {
"userid": ["zhangsan", "lisi", "wangwu", "zhaoliu"]
},
"settings": {
"remind_scope": 4,
"ring_users": {
"userid": ["zhangsan", "lisi"]
},
"allow_enter_before_host": true
}
}
```
@@ -0,0 +1,22 @@
# 创建会议 - 安全设置场景示例
## 场景 4: 会议密码 + 等候室 + 主持人设置
**用户意图**: "帮我创建一个重要的客户汇报会议, 需要设置密码1234, 开启等候室, 不允许外部人员入会"
```json
{
"title": "客户汇报会议",
"meeting_start_datetime": "2026-03-19 14:00",
"meeting_duration": 5400,
"invitees": {
"userid": ["zhangsan", "lisi", "wangwu"]
},
"settings": {
"password": "1234",
"enable_waiting_room": true,
"allow_enter_before_host": false,
"allow_external_user": false
}
}
```
+150
View File
@@ -0,0 +1,150 @@
---
name: wecomcli-edit-meeting
description: 企业微信会议管理技能, 支持取消会议和更新会议受邀成员. 当用户需要"取消会议", "删除会议", "修改会议成员", "添加会议参与人", "移除会议成员"时触发.
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli meeting --help"
---
# 企业微信会议管理技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
## 概述
wecomcli-edit-meeting 提供企业微信会议管理能力, 包含以下功能:
1. **取消会议** - 取消指定的预约会议
2. **更新会议受邀成员** - 修改会议的参与人列表
## 命令调用方式
调用指定命令:
```bash
wecom-cli meeting <tool_name> '<json_params>'
```
---
## 命令详细说明
### 1. 取消会议 (cancel_meeting)
取消指定的预约会议.
#### 调用命令
```bash
wecom-cli meeting cancel_meeting '{"meetingid": "<会议id>"}'
```
#### 入参说明
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------------------- |
| `meetingid` | string | 是 | 会议 ID, 通过 `wecomcli-get-meeting` 技能获取 |
#### 返回参数
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
---
### 2. 更新会议受邀成员 (set_invite_meeting_members)
更新会议的受邀成员列表 (全量覆盖).
#### 调用命令
```bash
wecom-cli meeting set_invite_meeting_members '{"meetingid": "<会议id>", "invitees": [{"userid": "lisi"}, {"userid": "wangwu"}]}'
```
#### 入参说明
| 参数 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | -------------------------------------- |
| `meetingid` | string | 是 | 会议 ID, 通过 `wecomcli-get-meeting` 技能获取 |
| `invitees` | array | 否 | 受邀成员列表, 每项包含 `userid` 字段 |
> **注意**: invitees 为全量覆盖, 传入的列表将替换现有成员列表.
> invitees 的 userid 通过 `wecomcli-lookup-contact` 技能获取
#### 返回参数
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
---
## 典型工作流
### 工作流 1: 取消会议
**示例**: 用户说 "帮我取消明天的技术方案评审会议"
**步骤:**
1. **定位会议**: 通过 `wecomcli-get-meeting` 技能查询会议列表 + 关键词匹配找到目标会议.
2. **直接执行取消**:
```bash
wecom-cli meeting cancel_meeting '{"meetingid": "<target_meetingid>"}'
```
4. **展示结果**:
```
✅ 会议已取消: 技术方案评审
```
---
### 工作流 2: 更新会议成员
**示例**: 用户说 "把王五加到技术方案评审会议里"
**步骤:**
1. **定位会议**: 通过 `wecomcli-get-meeting` 技能查询会议列表 + 匹配找到目标会议.
2. **获取当前受邀成员**: `set_invite_members` 为全量覆盖, 必须先通过 `wecomcli-get-meeting` 技能的 `get_meeting_info` 获取会议详情, 获取现有成员后再合并.
3. **通讯录查询**: 调用 `wecomcli-lookup-contact` 技能获取通讯录成员, 按姓名筛选出王五的 userid.
```bash
wecom-cli contact get_userlist '{}'
```
在返回的 `userlist` 中筛选 `name` 包含 "王五" 的成员, 获取其 `userid`.
4. **合并成员列表**: 将现有成员 + 新增成员合并 (全量覆盖).
5. **执行更新**:
```bash
wecom-cli meeting set_invite_meeting_members '{"meetingid": "<target_meetingid>", "invitees": [{"userid": "zhangsan"}, {"userid": "lisi"}, {"userid": "wangwu"}]}'
```
7. **展示结果**:
```
✅ 会议成员已更新: 技术方案评审
👥 当前成员: 张三, 李四, 王五
```
---
## 注意事项
- **参与人仅支持企业内成员**, 不支持外部人员
- **通讯录查询**: 涉及参与人时, 需先通过 `wecomcli-lookup-contact` 技能的 `get_userlist` 接口获取全量通讯录成员, 再按姓名/别名本地筛选匹配出对应的 `userid`. 该接口无入参, 返回当前用户可见范围内的成员列表 (含 `userid`, `name`, `alias`)
- **定位会议**: 管理操作需先通过 `wecomcli-get-meeting` 技能查询到目标会议的 meetingid
- **成员更新为全量覆盖**: `set_invite_members` 传入的列表将替换现有成员列表, 需先获取当前成员再合并
+267
View File
@@ -0,0 +1,267 @@
---
name: wecomcli-edit-todo
description: 企业微信待办事项编辑技能,支持创建、更新、删除待办及变更用户处理进度状态。在用户说"帮我创建一个待办"、"把这个任务分派给张三"、"标记待办完成"、"删掉那个待办"、"帮我建个提醒"、"更新一下待办内容"、"把提醒时间改到下周"、"接受这个待办"、"拒绝这个待办"等需要对待办进行写操作的场景时使用。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli todo --help"
---
# 企业微信待办事项编辑技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
通过 `wecom-cli` 对企业微信待办事项进行写操作,支持四种操作:创建待办、更新待办、删除待办、变更用户状态。
## 行为策略
**重试策略**: 遭遇"返回 HTTP 错误"或"HTTP 请求失败"时,主动重试,最多重试三次。
---
## 操作
### 1. 创建待办
创建一个新的待办事项,可指定内容、分派人和提醒时间:
```bash
wecom-cli todo create_todo '<json格式的入参>'
```
**参数说明:**
需要遵循 “注意事项”中的格式要求:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `content` | string | ✅ | 待办内容 |
| `follower_list` | object | ❌ | 分派人列表,格式见注意事项第 7 条 |
| `remind_time` | string | ❌ | 提醒时间,格式:`YYYY-MM-DD HH:mm:ss` |
**调用示例:**
```bash
wecom-cli todo create_todo '{"content": "<待办的内容>", "remind_time": "2025-06-01 09:00:00"}'
```
**返回格式:**
```json
{
"errcode": 0,
"errmsg": "ok",
"todo_id": "TODO_ID"
}
```
---
### 2. 更新待办
修改已有待办事项的内容、分派人、状态或提醒时间:
```bash
wecom-cli todo update_todo '<json格式的入参>'
```
**参数说明:**
需要遵循 “注意事项”中的格式要求:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `todo_id` | string | ✅ | 待办 ID |
| `content` | string | ❌ | 新的待办内容 |
| `follower_list` | object | ❌ | 新的分派人列表(全量替换,非追加),格式见注意事项第 7 条。若要新增分派人,需先查出现有分派人,合并后一起提交 |
| `todo_status` | number | ❌ | 新的待办状态:`0`-已完成,`1`-进行中。删除请使用 `delete_todo` |
| `remind_time` | string | ❌ | 新的提醒时间 |
**调用示例:**
```bash
wecom-cli todo update_todo '{"todo_id": "TODO_ID", "content": "<待办的内容>", "remind_time": "2025-07-01 09:00:00"}'
```
**返回格式:**
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
---
### 3. 删除待办
删除指定的待办事项:
```bash
wecom-cli todo delete_todo '<json格式的入参>'
```
**参数说明:**
需要遵循 “注意事项”中的格式要求:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `todo_id` | string | ✅ | 待办 ID |
**调用示例:**
```bash
wecom-cli todo delete_todo '{"todo_id": "TODO_ID"}'
```
**返回格式:**
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
> 删除操作不可撤销,执行前应向用户确认。
> 注意:`delete_todo` 与 `update_todo` 设置 `todo_status=2` 效果相同,优先使用 `delete_todo`。
---
### 4. 变更用户待办状态
更改当前用户在某个待办中的状态(拒绝/接受/已完成):
```bash
wecom-cli todo change_todo_user_status '<json格式的入参>'
```
**参数说明:**
需要遵循 “注意事项”中的格式要求:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `todo_id` | string | ✅ | 待办 ID |
| `user_status` | number | ✅ | 用户状态:`0`-拒绝,`1`-接受,`2`-已完成 |
**调用示例:**
```bash
wecom-cli todo change_todo_user_status '{"todo_id": "TODO_ID", "user_status": 2}'
```
**返回格式:**
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
---
## 典型工作流
### 创建待办并分派给同事
用户问:"帮我创建一个待办,让张三下周一前完成需求文档"
1. 第一步:通过 wecomcli-lookup-contact 技能查询张三的 userid,在返回结果中筛选姓名为"张三"的成员,获取其 userid
2. 第二步:创建待办并分派:`wecom-cli todo create_todo '{"content": "<待办的内容>", "follower_list": {"followers": [{"follower_id": "zhangsan", "follower_status": 1}]}, "remind_time": "2025-03-24 09:00:00"}'`
> `follower_id` 必须来自 `wecomcli-lookup-contact` 技能的 `get_userlist` 接口返回的 `userid`,禁止自行猜测。若搜索结果有多个同名人员,需展示候选列表让用户确认。
### 标记待办完成
需要区分两种场景:**标记待办本身完成**(改 `todo_status`)和**标记我的参与状态为完成**(改 `user_status`)。
#### 场景 A:标记待办本身完成
用户问:"把'完成Q2规划文档'这个待办标记为完成" / "关闭这个待办"
1. 第一步:通过 wecomcli-get-todo-list 获取待办列表,找到目标待办的 todo_id:`wecom-cli todo get_todo_list '{}'`
2. 第二步:通过 wecomcli-get-todo-detail 获取详情,确认是目标待办:`wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID"]}'`
3. 第三步:确认后,将待办状态改为已完成:`wecom-cli todo update_todo '{"todo_id": "TODO_ID", "todo_status": 0}'`
#### 场景 B:标记我的参与状态为完成
用户问:"我已经完成了这个待办" / "标记我的部分为完成"
1. 第一步:通过 wecomcli-get-todo-list 获取待办列表,找到目标待办的 todo_id:`wecom-cli todo get_todo_list '{}'`
2. 第二步:通过 wecomcli-get-todo-detail 获取详情,确认是目标待办:`wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID"]}'`
3. 第三步:确认后,变更当前用户的参与状态为已完成:`wecom-cli todo change_todo_user_status '{"todo_id": "TODO_ID", "user_status": 2}'`
> **如何判断用户意图:** 如果用户说"标记完成"且该待办是自己创建的、没有其他分派人,通常指场景 A(标记待办本身完成)。如果该待办有多个参与人,用户可能只是想标记自己那部分完成(场景 B)。不确定时应向用户确认。
> 用户提供的是待办内容描述而非 ID,所以需要先通过 wecomcli-get-todo-list 和 wecomcli-get-todo-detail 查找再匹配。匹配到多个相似待办时,列出候选项让用户确认。
### 更新待办内容或提醒时间
用户问:"把那个需求文档的待办提醒时间改到下周五"
1. 第一步:查找目标待办:`wecom-cli todo get_todo_list '{}'`,再查详情:`wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID_1", "TODO_ID_2"]}'`
2. 第二步:确认目标后更新:`wecom-cli todo update_todo '{"todo_id": "TODO_ID", "remind_time": "2025-03-28 09:00:00"}'`
### 删除待办
用户问:"删掉'代码评审'那个待办"
1. 第一步:查找目标待办:`wecom-cli todo get_todo_list '{}'`,再查详情:`wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID"]}'`
2. 第二步:向用户确认后删除:`wecom-cli todo delete_todo '{"todo_id": "TODO_ID"}'`
> 删除前必须向用户确认,确认措辞示例:"确认删除待办'代码评审'吗?删除后不可恢复。"
---
## 注意事项
1. **todo_id 来源规则**
- `todo_id` 必须来自 `wecomcli-get-todo-list` 返回的结果,禁止自行推测或构造
- 用户通常提供待办内容描述而非 ID,应先通过 wecomcli-get-todo-list 查列表再匹配
- 若匹配到多个相似待办,展示候选列表让用户确认
2. **follower_id 来源规则**
- `follower_id``userid`,必须通过 `wecomcli-lookup-contact` 技能的 `get_userlist` 接口获取
- 禁止根据用户姓名自行猜测 userid
- 若搜索结果有多个同名人员,展示候选列表让用户选择
3. **时间格式**
- 所有时间参数使用 `YYYY-MM-DD HH:mm:ss` 格式
- 用户说"明天"、"下周一"等相对时间时,根据当前日期推算具体日期
4. **状态值含义**
- 待办状态(`todo_status`):`0`-已完成,`1`-进行中,`2`-已删除
- 用户状态(`user_status`):`0`-拒绝,`1`-接受,`2`-已完成
- 分派人状态(`follower_status`):`0`-拒绝,`1`-接受,`2`-已完成
5. **破坏性操作确认**
- 删除待办(`delete_todo`)前必须向用户确认
- 变更状态为"拒绝"`user_status=0`)前建议向用户确认
6. **错误处理**
-`errcode` 不为 `0`,说明接口调用失败,告知用户 `errmsg` 中的错误信息
7. **follower_list** 的格式(作为输入参数的时候)
```json
"follower_list": { // 分派人列表
"followers": [ // 注意里面还有一层是 "followers",它的value才是真正的列表数组
{
"follower_id": "FOLLOWER_ID", // 分派人id
"follower_status": 1 // 分派人状态:0-拒绝, 1-接受, 2-已完成
}
]
}
```
> `follower_id` 即 userid,需要通过 `wecomcli-lookup-contact` 查询获取,禁止自行猜测或构造。
## 相关技能
- **获取待办列表**`wecomcli-get-todo-list` — 查询待办概要列表,获取 todo_id
- **获取待办详情**`wecomcli-get-todo-detail` — 根据 todo_id 获取完整内容
- **通讯录查询**`wecomcli-lookup-contact` 的 `get_userlist` — 获取成员 userid,用于 `follower_id`
+348
View File
@@ -0,0 +1,348 @@
---
name: wecomcli-get-meeting
description: 企业微信会议查询技能, 支持查询会议列表, 获取会议详情, 按关键词查找会议. 当用户需要"查看会议", "查询会议列表", "会议详情", "什么时候开会", "有哪些会议", "查找会议"时触发.
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli meeting --help"
---
# 企业微信会议查询技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
## 概述
wecomcli-get-meeting 提供企业微信会议查询能力, 包含以下功能:
1. **查询会议列表** - 按用户和时间范围查询会议 ID 列表 (限制: 当日及前后 30 天, 上限 100 个)
2. **获取会议详情** - 通过会议 ID 查询完整会议信息
## 命令调用方式
执行指定命令:
```bash
wecom-cli meeting <tool_name> '<json_params>'
```
---
## 命令详细说明
### 1. 查询会议列表 (list_user_meetings)
查询指定用户在时间范围内的会议 ID 列表.
#### 执行命令
```bash
wecom-cli meeting list_user_meetings '{"begin_datetime": "2026-03-01 00:00", "end_datetime": "2026-03-31 23:59", "limit": 100}'
```
#### 入参说明
| 参数 | 类型 | 必填 | 说明 |
| ------------------ | ------- | ---- | --------------------------------------- |
| `begin_datetime` | string | 否 | 查询起始时间, 格式:`YYYY-MM-DD HH:mm` |
| `end_datetime` | string | 否 | 查询结束时间, 格式:`YYYY-MM-DD HH:mm` |
| `cursor` | string | 否 | 分页游标, 用于获取下一页数据 |
| `limit` | integer | 否 | 每页返回条数, 最大 100 |
> **限制**: 时间范围仅支持当日及前后 30 天.
#### 返回参数
```json
{
"errcode": 0,
"errmsg": "ok",
"next_cursor": "分页游标字符串, 为空表示无更多",
"meetingid_list": ["会议ID_1", "会议ID_2"]
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------ | ------------------------------ |
| `meetingid_list` | array | 会议 ID 列表 |
| `next_cursor` | string | 下一页游标, 为空表示无更多数据 |
---
### 2. 获取会议详情 (get_meeting_info)
通过会议 ID 查询会议的完整详情.
#### 执行命令
```bash
wecom-cli meeting get_meeting_info '{"meetingid": "<会议id>"}'
```
#### 入参说明
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `meetingid` | string | 是 | 会议 ID, 通过 `list_user_meetings` 获取 |
| `meeting_code` | string | 否 | 会议号码 |
| `sub_meetingid` | string | 否 | 子会议 ID |
#### 返回参数
```json
{
"errcode": 0,
"errmsg": "ok",
"creator_userid": "创建者userid",
"admin_userid": "会议管理userid (与 creator_userid 有且仅返回一个)",
"title": "会议标题",
"meeting_start_datetime": "YYYY-MM-DD HH:mm",
"meeting_duration": ,
"description": "会议描述文本",
"location": "会议地点文本",
"main_department": ID,
"status": ,
"meeting_type": ,
"attendees": {
"member": [
{
"userid": "内部成员userid",
"status": ,
"first_join_datetime": "YYYY-MM-DD HH:mm",
"last_quit_datetime": "YYYY-MM-DD HH:mm",
"total_join_count": ,
"cumulative_time":
}
],
"tmp_external_user": [
{
"tmp_external_userid": "外部临时用户ID",
"status": ,
"first_join_datetime": "YYYY-MM-DD HH:mm",
"last_quit_datetime": "YYYY-MM-DD HH:mm",
"total_join_count": ,
"cumulative_time":
}
]
},
"settings": {
"remind_scope": ,
"need_password": ,
"password": "会议密码",
"enable_waiting_room": ,
"allow_enter_before_host": ,
"enable_enter_mute": ,
"allow_unmute_self": ,
"allow_external_user": ,
"enable_screen_watermark": ,
"watermark_type": ,
"auto_record_type": "录制类型枚举字符串",
"attendee_join_auto_record": ,
"enable_host_pause_auto_record": ,
"enable_doc_upload_permission": ,
"enable_enroll": ,
"enable_host_key": ,
"host_key": "主持人密钥字符串",
"hosts": {"userid": ["主持人userid列表"]},
"current_hosts": {"userid": ["当前主持人userid列表"]},
"co_hosts": {"userid": ["联席主持人userid列表"]},
"ring_users": {"userid": ["响铃用户userid列表"]}
},
"meeting_code": "会议号码字符串",
"meeting_link": "会议链接URL",
"has_vote": ,
"has_more_sub_meeting": ,
"remain_sub_meetings": ,
"current_sub_meetingid": "当前子会议ID",
"guests": [
{
"area": "国际区号",
"phone_number": "手机号字符串",
"guest_name": "嘉宾姓名"
}
],
"reminders": {
"is_repeat": ,
"repeat_type": ,
"repeat_until_type": ,
"repeat_until_count": ,
"repeat_until_datetime": "YYYY-MM-DD HH:mm",
"repeat_interval": ,
"is_custom_repeat": ,
"repeat_day_of_week": [],
"repeat_day_of_month": [],
"remind_before": []
},
"sub_meetings": [
{
"sub_meetingid": "子会议ID",
"status": ,
"start_datetime": "YYYY-MM-DD HH:mm",
"end_datetime": "YYYY-MM-DD HH:mm",
"title": "子会议标题",
"repeat_id": "周期性会议分段ID"
}
],
"sub_repeat_list": [
{
"repeat_id": "周期性会议分段ID",
"repeat_type": ,
"repeat_until_type": ,
"repeat_until_count": ,
"repeat_until_datetime": "YYYY-MM-DD HH:mm",
"repeat_interval": ,
"is_custom_repeat": ,
"repeat_day_of_week": [],
"repeat_day_of_month": []
}
]
}
```
**关键返回字段:**
| 字段 | 类型 | 说明 |
| ----------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- |
| `creator_userid` | string | 创建者 userid, 与 `admin_userid` 有且仅返回一个 |
| `admin_userid` | string | 会议管理 userid, 与 `creator_userid` 有且仅返回一个 |
| `title` | string | 会议标题 |
| `meeting_start_datetime` | string | 会议开始时间 |
| `meeting_duration` | integer | 会议时长 (秒) |
| `main_department` | integer | 创建者所属主部门 |
| `status` | integer | 会议状态 (1: 待开始, 2: 会议中, 3: 已结束, 4: 已取消, 5: 已过期) |
| `meeting_type` | integer | 会议类型 (0: 一次性会议, 1: 周期性会议, 2: 微信专属会议, 3: Rooms 投屏会议, 5: 个人会议号会议, 6: 网络研讨会) |
| `meeting_code` | string | 会议号码 |
| `meeting_link` | string | 会议链接 |
| `attendees.member` | array | 内部参与者列表 |
| `attendees.member[].status` | integer | 与会状态 (1: 已参与, 2: 未参与) |
| `attendees.tmp_external_user` | array | 外部参与者 (临时 ID) |
| `attendees.tmp_external_user[].status` | integer | 与会状态 (1: 已参与, 2: 未参与) |
| `guests` | array | 外部嘉宾列表, 每项含 `area`, `phone_number`, `guest_name` |
| `current_sub_meetingid` | string | 当前子会议 ID |
| `settings.ring_users` | object | 响铃用户列表 |
| `settings.need_password` | boolean | 是否需要密码 (只读字段) |
| `settings.enable_doc_upload_permission` | boolean | 是否允许成员上传文档 |
| `settings.hosts` | object | 主持人列表 |
| `settings.current_hosts` | object | 当前主持人列表 |
| `settings.co_hosts` | object | 联席主持人列表 |
| `reminders` | object | 周期性配置 |
| `has_vote` | boolean | 是否有投票 (仅会议创建人和主持人有权限查询) |
| `has_more_sub_meeting` | integer | 是否还有更多子会议特例 (0: 无更多, 1: 有更多) |
| `remain_sub_meetings` | integer | 剩余子会议场数 |
| `sub_meetings` | array | 子会议列表 |
| `sub_meetings[].status` | integer | 子会议状态 (0: 默认/存在, 1: 已删除) |
| `sub_meetings[].repeat_id` | string | 周期性会议分段 ID, 用于关联子会议所属分段 |
| `sub_repeat_list` | array | 周期性会议分段信息, 修改周期性会议某一场后可能产生不同分段, 各分段有不同重复规则 |
---
## 典型工作流
### 工作流 1: 查询会议列表
**示例**: 用户说 "帮我查一下本周有哪些会议"
**步骤:**
1. **确定时间范围**: 根据当前日期计算本周的起止时间.
2. **查询会议 ID 列表**:
```bash
wecom-cli meeting list_user_meetings '{"begin_datetime": "2026-03-16 00:00", "end_datetime": "2026-03-22 23:59", "limit": 100}'
```
3. **逐个查询会议详情** (对返回的每个 meetingid):
```bash
wecom-cli meeting get_meeting_info '{"meetingid": "<会议id1>"}'
```
```bash
wecom-cli meeting get_meeting_info '{"meetingid": "<会议id2>"}'
```
4. **汇总展示**:
```
📋 本周会议列表 (共 3 场):
1. 📅 技术方案评审
🕐 2026-03-17 10:00 - 11:00
👥 张三, 李四, 王五
2. 📅 产品需求沟通
🕐 2026-03-18 14:00 - 15:00
👥 赵六, 钱七
3. 📅 周五周会
🕐 2026-03-21 09:00 - 10:00
👥 全组成员
```
> **分页处理**: 如果 `next_cursor` 不为空, 使用 `cursor` 参数继续拉取下一页.
---
### 工作流 2: 获取会议详情
**示例**: 用户说 "帮我看下技术方案评审会议的详情"
**步骤:**
1. **定位会议**: 先通过会议列表查询找到目标会议的 meetingid (按关键词匹配).
2. **查询详情**:
```bash
wecom-cli meeting get_meeting_info '{"meetingid": "<target_meetingid>"}'
```
3. **展示结果**:
#会议号: <会议号>
```
📅 <会议标题>
🕐 时间: <开始时间>, 时长 <时长>
📍 地点: <会议地点>
📝 描述: <会议描述>
👤 创建者: <创建者姓名>
👥 参与者: <参与者姓名列表>
🔗 会议链接: <会议链接>
```
---
### 工作流 3: 根据关键词查找会议
**示例**: 用户说 "技术评审会议是什么时候?"
**查询策略:**
1. **确定查询范围**: 默认查当日前后 30 天 (接口限制范围).
2. **拉取会议列表**:
```bash
wecom-cli meeting list_user_meetings '{"begin_datetime": "2026-02-15 00:00", "end_datetime": "2026-04-16 23:59", "limit": 100}'
```
3. **逐个查询详情并匹配标题关键词**.
4. **找到匹配后停止查询, 展示结果**:
#会议号: <会议号>
```
✅ 找到会议: "<会议标题>"
📅 时间: <开始时间>, 时长 <时长>
📍 地点: <会议地点>
👥 参与者: <参与者姓名列表>
🔗 会议链接: <会议链接>
```
5. **未找到处理**: 告知用户在前后 30 天范围内未找到匹配会议, 请确认会议名称.
---
## 注意事项
- **时间格式**: 统一使用 `YYYY-MM-DD HH:mm` 格式
- **会议列表时间范围限制**: 仅支持查询当日及前后 30 天内的会议
- **查询详情需两步**: 先通过 `list_user_meetings` 获取会议 ID 列表, 再通过 `get_meeting_info` 逐个获取详情
+164
View File
@@ -0,0 +1,164 @@
---
name: wecomcli-get-msg
description: 企业微信消息技能。提供会话列表查询、消息记录拉取(支持文本/图片/文件/语音/视频)、多媒体文件获取和文本消息发送能力。当用户需要"查看消息"、"看聊天记录"、"发消息给某人"、"最近有什么消息"、"给群里发消息"、"看看发了什么图片/文件"时触发。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli msg --help"
---
# 企业微信消息技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
通过 `wecom-cli msg <接口名> '<json入参>'` 与企业微信消息系统交互。
---
## 接口列表
### get_msg_chat_list — 获取会话列表
```bash
wecom-cli msg get_msg_chat_list '{"begin_time": "2026-03-11 00:00:00", "end_time": "2026-03-17 23:59:59"}'
```
按时间范围查询有消息的会话列表,支持分页。参见 [API 详情](references/api-get-msg-chat-list.md)。
### get_message — 拉取会话消息
```bash
wecom-cli msg get_message '{"chat_type": 1, "chatid": "zhangsan", "begin_time": "2026-03-17 09:00:00", "end_time": "2026-03-17 18:00:00"}'
```
根据会话类型和 ID 拉取指定时间范围内的消息记录,支持分页。支持 text/image/file/voice/video 消息类型,仅支持 7 天内。参见 [API 详情](references/api-get-message.md)。
### get_msg_media — 获取消息文件内容
```bash
wecom-cli msg get_msg_media '{"media_id": "MEDIAID_xxxxxx"}'
```
根据文件 ID 自动下载文件到本地,返回文件的本地路径(`local_path`)、名称、类型、大小及 MIME 类型。用于获取图片、文件、语音、视频等非文本消息的实际内容。参见 [API 详情](references/api-get-msg-media.md)。
### send_message — 发送文本消息
```bash
wecom-cli msg send_message '{"chat_type": 1, "chatid": "zhangsan", "msgtype": "text", "text": {"content": "hello world"}}'
```
向单聊或群聊发送文本消息。参见 [API 详情](references/api-send-message.md)。
---
## 核心规则
### 时间范围规则
- **格式**:所有时间参数使用 `YYYY-MM-DD HH:mm:ss` 格式
- **默认范围**:用户未指定时,默认使用最近7天(当前时间往前推7天)
- **限制**:开始时间不能早于当前时间的7天前,不能晚于当前时间
- **相对时间支持**:支持"昨天"、"最近三天"等自动推算
### chatid查找规则
- 当用户提供人名或群名而非ID时:
1. 调用 `get_msg_chat_list` 获取会话列表(时间范围与目标查询一致)
2.`chats` 中按 `chat_name` 匹配
3. **匹配策略**
- 精确匹配唯一结果:直接使用
- 模糊匹配多个结果:展示候选列表让用户选择
- 无匹配结果:告知用户未找到
- **chat_type 判断**`get_msg_chat_list` 返回中不含会话类型字段,需根据上下文推断:用户明确提到「群」时使用 `chat_type=2`,否则默认 `chat_type=1`(单聊)
### userid转username
**流程**
1. 调用 `wecomcli-lookup-contact get_userlist` 获取用户列表
2. 建立userid到username的映射关系
3. **展示策略**
- 精确匹配:显示username
- 无匹配:保持显示userid
### 强制交互步骤(不可跳过)
以下步骤在涉及非文本消息下载时**必须逐一执行**,不得合并、省略或跳过,即使用户未主动询问也必须执行:
1. **必须主动告知文件位置**:下载完成后必须立即向用户展示所有文件的完整路径和存放目录
2. **必须询问是否删除**:告知位置后必须立即询问用户是否需要清理临时文件
---
## 典型工作流
### 查看会话列表
**用户query示例**
- "看看我最近一周有哪些聊天"
- "这几天谁给我发过消息"
**执行流程**
1. 确定时间范围(用户指定或默认最近7天)
2. 调用 `get_msg_chat_list` 获取会话列表
3. 展示会话名称、最后消息时间、消息数量
4.`has_more``true`,告知用户还有更多会话可继续查看
### 查看聊天记录
**用户query示例**
- "帮我看看和张三最近的聊天记录"
- "看看项目群里最近的消息"
**执行流程**
1. 确定时间范围(用户指定或默认最近7天)
2. 通过 **chatid查找规则** 确定目标会话的 `chatid``chat_type`
3. 调用 `get_message` 拉取消息列表
4. 调用 `wecomcli-lookup-contact``get_userlist` 获取通讯录,建立 userid→姓名 映射
5. **统计非文本消息**:遍历消息列表,统计 `msgtype``text` 的消息(image/file/voice/video)数量和类型
6. 展示消息时将 `userid` 替换为可读姓名,格式:
- 文本消息:`姓名 [时间]: 内容`
- 图片消息:`姓名 [时间]:[图片]`
- 文件消息:`姓名 [时间]:[文件] 文件名称`
- 语音消息:`姓名 [时间]:[语音] 语音内容`
- 视频消息:`姓名 [时间]:[视频]`
7. **非文本消息处理**:展示完消息后,如果存在非文本消息:
- **主动询问是否下载**:告知用户非文本消息数量和类型(如:"以上聊天中包含 2 张图片、1 个文件,是否需要下载到本地?")
- 用户确认后,逐个调用 `get_msg_media` 接口,接口会自动下载文件并返回 `local_path`
- **检查文件后缀**:每个文件下载完成后,检查 `local_path` 对应的文件是否具有正确的后缀名:
- 根据 `get_msg_media` 返回的 `content_type`MIME 类型)和 `name` 字段判断:
- 如果文件名缺少后缀(如 `screenshot` 而非 `screenshot.png`),根据 `content_type` 自动补上正确后缀(如 `image/png``.png``application/pdf``.pdf``audio/amr``.amr``video/mp4``.mp4`
- 如果文件名后缀与 `content_type` 不一致,以 `content_type` 为准进行修正
- 补全或修正后缀后,将文件重命名为正确的文件名
- 确认文件可正常读取(文件大小 > 0),若文件为空或损坏则告知用户该文件下载异常
- ⚠️ **不要对下载的文件使用 `MEDIA:` 指令**:这些文件是从聊天记录中下载的历史附件,仅需告知用户本地存放路径即可,**严禁**通过 `MEDIA:` 指令重新发送给用户
8. ⚠️ **必须主动告知文件位置**(此步骤不可跳过):所有文件下载并检查完成后,**必须立即、主动**以汇总形式向用户展示文件存放目录和每个文件的完整路径,不要等用户询问。示例:
> 📁 文件已下载到以下位置:
> - 图片:`xxx/yyy.png`
> - 文件:`xxx/yyy.pdf`
>
> 你可以在 `xxx/yyy/` 目录下找到所有下载的文件。
9. ⚠️ **必须询问是否删除**(此步骤不可跳过):告知文件位置后,**必须立即、主动**询问用户是否需要删除已下载的临时文件(如:"如果不再需要这些文件,是否需要我帮你清理?")
- 用户确认删除后,删除 `local_path` 对应的文件
- 用户不需要删除则保留文件
10.`next_cursor` 不为空,告知用户还有更多消息可继续查看
### 发送消息
**用户query示例**
- "帮我给张三发一条消息:明天会议改到下午3点"
- "在项目群里发一条消息:今天下午3点开会"
**执行流程**
1. 通过 **chatid查找规则** 确定目标会话的 `chatid``chat_type`
2. **发送前确认**:向用户确认发送对象和内容(如:"即将向 张三 发送:'明天会议改到下午3点',确认发送吗?"),用户确认后再执行
3. 调用 `send_message` 发送(`msgtype` 固定为 `text`
4. 展示发送结果
### 查看消息并回复
**用户query示例**
- "看看张三给我发了什么,然后帮我回复收到"
**执行流程**
1. 先执行"查看聊天记录"流程(复用已获取的 `chatid``chat_type`
2. 展示消息后,执行"发送消息"流程(需确认后再发送)
---
## 错误处理
- **时间范围超限**:告知用户7天限制并调整为有效范围
- **会话未找到**:明确告知用户未找到对应会话
- **API错误**:展示具体错误信息,必要时重试
- **网络问题**HTTP错误时主动重试最多3次`
@@ -0,0 +1,99 @@
# get_message API
根据会话类型和会话 ID,拉取指定时间范围内的消息记录。支持文本、图片、文件、语音、视频类型消息。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `chat_type` | integer | ✅ | 会话类型,`1`-单聊,`2`-群聊 |
| `chatid` | string | ✅ | 会话 ID,单聊时为 userid,群聊时为群 ID,最大 256 字节 |
| `begin_time` | string | ✅ | 拉取开始时间,格式:`YYYY-MM-DD HH:mm:ss`,仅支持请求时刻往前 **7 天**内 |
| `end_time` | string | ✅ | 拉取结束时间,格式:`YYYY-MM-DD HH:mm:ss`,必须 ≥ `begin_time` |
| `cursor` | string | ❌ | 分页游标,首次请求不传,后续传入上次响应的 `next_cursor`,最大 256 字节 |
## 请求示例
单聊:
```bash
wecom-cli msg get_message '{"chat_type": 1, "chatid": "zhangsan", "begin_time": "2026-03-17 09:00:00", "end_time": "2026-03-17 18:00:00"}'
```
群聊:
```bash
wecom-cli msg get_message '{"chat_type": 2, "chatid": "wrxxxxxxxx", "begin_time": "2026-03-17 09:00:00", "end_time": "2026-03-17 18:00:00"}'
```
分页请求:
```bash
wecom-cli msg get_message '{"chat_type": 1, "chatid": "zhangsan", "begin_time": "2026-03-17 09:00:00", "end_time": "2026-03-17 18:00:00", "cursor": "CURSOR_xxxxxx"}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | Integer | 返回码,`0` 表示成功 |
| `errmsg` | String | 错误信息 |
| `messages` | Array | 消息列表 |
| `messages[].userid` | String | 消息发送者的 userid |
| `messages[].send_time` | String | 消息发送时间(北京时间),格式:`YYYY-MM-DD HH:mm:ss` |
| `messages[].msgtype` | String | 消息类型,`text`-文本消息,`image`-图片消息,`file`-文件消息,`voice`-语音消息,`video`-视频消息 |
| `messages[].text` | Object | 文本消息内容,`msgtype``text` 时返回 |
| `messages[].text.content` | String | 消息内容 |
| `messages[].image` | Object | 图片消息内容,`msgtype``image` 时返回 |
| `messages[].image.media_id` | String | 图片的 media_id,可通过 `get_msg_media` 接口下载 |
| `messages[].image.name` | String | 图片文件名称 |
| `messages[].file` | Object | 文件消息内容,`msgtype``file` 时返回 |
| `messages[].file.media_id` | String | 文件的 media_id,可通过 `get_msg_media` 接口下载 |
| `messages[].file.name` | String | 文件名称 |
| `messages[].voice` | Object | 语音消息内容,`msgtype``voice` 时返回 |
| `messages[].voice.media_id` | String | 语音的 media_id,可通过 `get_msg_media` 接口下载 |
| `messages[].video` | Object | 视频消息内容,`msgtype``video` 时返回 |
| `messages[].video.media_id` | String | 视频的 media_id,可通过 `get_msg_media` 接口下载 |
| `next_cursor` | String | 分页游标,为空表示已拉取完毕 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"messages": [
{
"userid": "zhangsan",
"send_time": "2026-03-17 09:30:00",
"msgtype": "text",
"text": {
"content": "你好"
}
},
{
"userid": "lisi",
"send_time": "2026-03-17 09:35:00",
"msgtype": "image",
"image": {
"media_id": "MEDIAID_xxxxxx",
"name": "screenshot.png"
}
},
{
"userid": "zhangsan",
"send_time": "2026-03-17 09:40:00",
"msgtype": "file",
"file": {
"media_id": "MEDIAID_yyyyyy",
"name": "report.pdf"
}
}
],
"next_cursor": "CURSOR_xxxxxx"
}
```
## 非文本消息处理
`msgtype``image``file``voice``video` 时,消息体中包含 `media_id`。需要调用 [get_msg_media](api-get-msg-media.md) 接口获取文件的本地路径(`local_path`),再进行展示。
@@ -0,0 +1,62 @@
# get_msg_chat_list API
获取指定时间范围内有消息的会话列表,支持分页查询。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `begin_time` | string | ✅ | 拉取开始时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `end_time` | string | ✅ | 拉取结束时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `cursor` | string | ❌ | 分页游标,首次请求不传,后续传入上次响应的 `next_cursor`,最大长度 256 |
## 请求示例
```bash
wecom-cli msg get_msg_chat_list '{"begin_time": "2026-03-11 00:00:00", "end_time": "2026-03-17 23:59:59"}'
```
分页请求:
```bash
wecom-cli msg get_msg_chat_list '{"begin_time": "2026-03-11 00:00:00", "end_time": "2026-03-17 23:59:59", "cursor": "NEXT_CURSOR"}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
| `chats` | array | 会话列表 |
| `chats[].chat_id` | string | 会话 ID |
| `chats[].chat_name` | string | 会话名称 |
| `chats[].last_msg_time` | string | 最后一条消息时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `chats[].msg_count` | integer | 消息数量 |
| `has_more` | boolean | 是否还有更多数据 |
| `next_cursor` | string | 分页游标,用于下一次请求 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"chats": [
{
"chat_id": "CHAT_ID",
"chat_name": "张三",
"last_msg_time": "2026-03-17 15:30:45",
"msg_count": 128
},
{
"chat_id": "CHAT_ID_2",
"chat_name": "项目讨论群",
"last_msg_time": "2026-03-16 09:12:33",
"msg_count": 56
}
],
"has_more": true,
"next_cursor": "NEXT_CURSOR"
}
```
@@ -0,0 +1,46 @@
# get_msg_media API
获取消息文件内容。根据文件 ID 自动下载文件到本地,返回本地文件路径、文件名称、类型、大小及内容类型。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `media_id` | string | ✅ | 文件 ID,长度 1~256 |
## 请求示例
```bash
wecom-cli msg get_msg_media '{"media_id": "MEDIAID_xxxxxx"}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | Integer | 返回码,`0` 表示成功 |
| `errmsg` | String | 错误信息 |
| `media_item` | Object | 文件内容 |
| `media_item.media_id` | String | 文件 ID |
| `media_item.name` | String | 文件名称 |
| `media_item.type` | String | 文件类型,`image`-图片,`voice`-语音,`video`-视频,`file`-普通文件 |
| `media_item.local_path` | String | 文件下载后的本地路径 |
| `media_item.size` | Integer | 文件大小(字节) |
| `media_item.content_type` | String | 文件 MIME 类型,如 `image/png``application/pdf` 等 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"media_item": {
"media_id": "MEDIAID_xxxxxx",
"name": "screenshot.png",
"type": "image",
"local_path": "xxx/yyy/screenshot.png",
"size": 102400,
"content_type": "image/png"
}
}
```
@@ -0,0 +1,43 @@
# send_message API
向单聊或群聊发送文本消息。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `chat_type` | integer | ✅ | 会话类型,`1`-单聊,`2`-群聊 |
| `chatid` | string | ✅ | 会话 ID,单聊时为 userid,群聊时为群 ID,最大 256 字节 |
| `msgtype` | string | ✅ | 消息类型,目前仅支持 `text` |
| `text` | object | ✅ | 文本消息内容 |
| `text.content` | string | ✅ | 消息内容,最大 2048 字节 |
## 请求示例
单聊:
```bash
wecom-cli msg send_message '{"chat_type": 1, "chatid": "zhangsan", "msgtype": "text", "text": {"content": "hello world"}}'
```
群聊:
```bash
wecom-cli msg send_message '{"chat_type": 2, "chatid": "wrxxxxxxxx", "msgtype": "text", "text": {"content": "大家好"}}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
+161
View File
@@ -0,0 +1,161 @@
---
name: wecomcli-get-todo-detail
description: 企业微信待办详情批量查询技能,根据待办 ID 列表获取完整信息(包含待办内容和分派人)。在用户说"看看这个待办的详情"、"待办内容是什么"、"这个待办分派给谁了"、"告诉我待办的具体信息"等需要查看待办完整内容的场景时使用。通常配合 wecomcli-get-todo-list 使用——先获取待办 ID 列表,再用本技能获取详情。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli todo --help"
---
# 企业微信待办详情查询技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
通过 `wecom-cli` 根据待办 ID 列表批量查询完整详情,包含待办内容和分派人信息。
## 行为策略
**人员 ID 转姓名(关键步骤)**: 返回结果中的 `follower_id``creator_id` 都是系统内部 ID,直接展示给用户毫无意义——用户不认识这些 ID,只认识姓名。因此在向用户展示待办详情之前,必须先调用 `wecomcli-lookup-contact` 技能获取通讯录,将所有 `follower_id``creator_id` 匹配为真实姓名。具体做法:
```bash
wecom-cli contact get_userlist '{}'
```
如果通讯录中找不到某个 ID,展示时标注"未知用户(ID: xxx)"即可。
**重试策略**: 遭遇"返回 HTTP 错误"或"HTTP 请求失败"时,主动重试,最多重试三次。
---
## 调用方式
```bash
wecom-cli todo get_todo_detail '<json格式的入参>'
```
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `todo_id_list` | array | ✅ | 待办 ID 列表,最多 20 个 |
**调用示例:**
```bash
wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID_1", "TODO_ID_2"]}'
```
## 返回格式
```json
{
"errcode": 0,
"errmsg": "ok",
"data_list": [
{
"todo_id": "TODO_ID",
"todo_status": 1,
"content": "完成Q2规划文档",
"follower_list": {
"followers": [
{
"follower_id": "FOLLOWER_ID",
"follower_status": 1,
"update_time": "2025-01-16 14:20:00"
}
]
},
"creator_id": "CREATOR_ID",
"user_status": 1,
"remind_time": "2025-06-01 09:00:00",
"create_time": "2025-01-15 10:30:00",
"update_time": "2025-01-16 14:20:00"
}
]
}
```
## 返回字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `data_list` | array | 待办详情列表,最多 20 条 |
| `data_list[].todo_id` | string | 待办 ID |
| `data_list[].todo_status` | number | 待办状态:`0`-已完成,`1`-进行中,`2`-已删除 |
| `data_list[].content` | string | 待办内容 |
| `data_list[].follower_list.followers` | array | 分派人列表 |
| `data_list[].follower_list.followers[].follower_id` | string | 分派人 ID(即 userid)— **展示前需通过 wecomcli-lookup-contact 转为姓名** |
| `data_list[].follower_list.followers[].follower_status` | number | 分派人状态:`0`-拒绝,`1`-接受,`2`-已完成 |
| `data_list[].follower_list.followers[].update_time` | string | 分派人状态更新时间 |
| `data_list[].creator_id` | string | 创建人 ID — **展示前需通过 wecomcli-lookup-contact 转为姓名** |
| `data_list[].user_status` | number | 当前用户状态 |
| `data_list[].remind_time` | string | 提醒时间 |
| `data_list[].create_time` | string | 创建时间 |
| `data_list[].update_time` | string | 更新时间 |
---
## 典型工作流
### 列表 + 详情联合查询(三步缺一不可)
用户问:"看看我最近的待办" / "我有哪些待办事项?"
1. 第一步:通过 wecomcli-get-todo-list 获取待办列表。
```bash
wecom-cli todo get_todo_list '{}'
```
2. 第二步:根据返回的 todo_id 批量获取详情。
```bash
wecom-cli todo get_todo_detail '{"todo_id_list": ["TODO_ID_1", "TODO_ID_2", "TODO_ID_3"]}'
```
3. 第三步(不要跳过!):通过 wecomcli-lookup-contact 获取通讯录,将 follower_id / creator_id 转为姓名。用返回的 userlist 中的 userid 匹配 follower_id 和 creator_id,取 name 字段作为展示姓名
> 第三步是展示可读结果的前提。没有这一步,用户看到的是一串无意义的 ID 而非姓名。
**展示格式(注意:分派人和创建人必须显示为姓名,不是 ID):**
```
📋 您当前的待办事项(共 3 项)
1. 🔵 完成Q2规划文档
- 待办状态:进行中 | 我的状态:已接受
- 提醒时间:2025-06-01 09:00
- 分派人:张三、李四
- 创建时间:2025-01-15
2. 🔵 提交周报
- 待办状态:进行中 | 我的状态:已接受
- 提醒时间:2025-03-17 18:00
- 创建时间:2025-03-10
3. ☑️ 代码评审
- 待办状态:已完成 | 我的状态:已完成
- 创建时间:2025-03-01
```
---
## 注意事项
1. **人员 ID 必须转姓名**
- 返回结果中的 `follower_id``creator_id` 是系统内部标识,用户无法识别
- 展示待办详情前,先
```bash
wecom-cli contact get_userlist '{}'
```
获取通讯录
- 用通讯录的 `userid` 匹配 `follower_id` / `creator_id`,用 `name` 替换展示
2. **todo_id 来源规则**
- `todo_id` 必须来自 `wecomcli-get-todo-list` 返回的结果,禁止自行推测或构造
- 用户通常提供待办内容描述而非 ID,应先通过 `wecomcli-get-todo-list` 查列表再匹配
3. **状态值含义**
- 待办状态(`todo_status`):`0`-已完成,`1`-进行中,`2`-已删除
- 用户状态(`user_status`):`0`-拒绝,`1`-接受,`2`-已完成
- 分派人状态(`follower_status`):`0`-拒绝,`1`-接受,`2`-已完成
4. **错误处理**:若 `errcode` 不为 `0`,告知用户 `errmsg` 中的错误信息
5. **单次上限**`todo_id_list` 最多传 20 个 ID,超过需要分批请求
+147
View File
@@ -0,0 +1,147 @@
---
name: wecomcli-get-todo-list
description: 企业微信待办列表查询技能,支持按创建时间和提醒时间过滤,支持分页。在用户说"看看我的待办列表"、"我有哪些待办"、"这周的待办有哪些"、"最近有什么待办"、"查一下我的待办"、"列出所有待办"等需要浏览待办概览的场景时使用。注意:此技能仅返回待办概要信息(不含内容和分派人),如需完整详情请配合 wecomcli-get-todo-detail 使用。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli todo --help"
---
# 企业微信待办列表查询技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
通过 `wecom-cli` 查询企业微信待办列表,支持按时间过滤和分页。
## 行为策略
**查完列表必须查详情**: 本接口只返回待办 ID 和状态等概要信息,不包含待办的实际内容和分派人。对用户来说,没有内容的待办列表毫无用处——他们想知道的是"要做什么",而不是一串 ID。因此,每次调用 get_todo_list 拿到结果后,都要紧接着用返回的 todo_id 列表调用 `wecomcli-get-todo-detail` 获取完整详情(内容、分派人等),然后再向用户展示。这不是可选步骤,而是完成用户请求的必要环节。
**分页未拉完时必须提醒用户**: 接口是分页的,不要求一次性拉完所有数据。但如果响应中 `has_more``true`,说明后面还有待办没有返回——这时你在展示当前结果的同时,必须明确告诉用户"还有更多待办未显示,是否需要继续查看?"。用户可能不知道后面还有数据,如果你不说,他们会以为看到的就是全部,这会导致遗漏重要待办。这是一个容易被忽略但后果严重的点,请务必执行。
**重试策略**: 遭遇"返回 HTTP 错误"或"HTTP 请求失败"时,主动重试,最多重试三次。
---
## 调用方式
```bash
wecom-cli todo get_todo_list '<json格式的入参>'
```
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `create_begin_time` | string | ❌ | 创建开始时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `create_end_time` | string | ❌ | 创建结束时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `remind_begin_time` | string | ❌ | 提醒开始时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `remind_end_time` | string | ❌ | 提醒结束时间,格式:`YYYY-MM-DD HH:mm:ss` |
| `limit` | number | ❌ | 最大返回数量,默认 10,最大 20 |
| `cursor` | string | ❌ | 分页游标,首次请求不传,后续传入上次响应的 `next_cursor` |
## 返回格式
```json
{
"errcode": 0,
"errmsg": "ok",
"index_list": [
{
"todo_id": "TODO_ID",
"todo_status": 1,
"user_status": 1,
"creator_id": "CREATOR_ID",
"remind_time": "2025-06-01 09:00:00",
"create_time": "2025-01-15 10:30:00",
"update_time": "2025-01-16 14:20:00"
}
],
"next_cursor": "NEXT_CURSOR",
"has_more": false
}
```
## 返回字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `index_list` | array | 待办列表 |
| `index_list[].todo_id` | string | 待办唯一 ID |
| `index_list[].todo_status` | number | 待办状态:`0`-已完成,`1`-进行中,`2`-已删除 |
| `index_list[].user_status` | number | 用户状态:`0`-拒绝,`1`-接受,`2`-已完成 |
| `index_list[].creator_id` | string | 创建人 ID |
| `index_list[].remind_time` | string | 提醒时间 |
| `index_list[].create_time` | string | 创建时间 |
| `index_list[].update_time` | string | 更新时间 |
| `next_cursor` | string | 下一页游标 |
| `has_more` | boolean | 是否还有更多记录 |
> 列表返回的是待办概要信息(不含内容和分派人)。拿到列表后,必须调用 `wecomcli-get-todo-detail` 获取完整详情再展示给用户。
---
## 典型工作流
### 查看待办列表(标准两步流程)
用户问:"看看我最近的待办" / "我有哪些待办事项?" / "我还有多少事要做?"
1. 第一步:获取待办列表(只有 ID 和状态,没有内容)。
```bash
wecom-cli todo get_todo_list '{}'
```
2. 第二步(禁止跳过!):用返回的 todo_id 列表调用 wecomcli-get-todo-detail 获取完整详情。
```bash
wecom-cli todo get_todo_detail '{"todo_id_list": ["返回的TODO_ID_1", "返回的TODO_ID_2"]}'
```
两步缺一不可——只有拿到详情后,才能向用户展示有意义的待办内容。
3. 第三步(条件执行):检查第一步返回的 `has_more` 字段。如果为 `true`,在展示结果时必须提醒用户:"以上是部分待办,还有更多待办未显示,需要我继续查看吗?"——不提醒的话,用户会以为这就是全部。
### 按时间范围查询
用户问:"这个月创建的待办有哪些?"
```bash
wecom-cli todo get_todo_list '{"create_begin_time": "2025-03-01 00:00:00", "create_end_time": "2025-03-31 23:59:59"}'
```
### 分页获取大量待办
当待办数量超过单页上限时,通过 `cursor` 循环分页拉取:
- 首次请求(不传 cursor):
```bash
wecom-cli todo get_todo_list '{"limit": 20}'
```
,如果没有拉取完,还有更多的待办,会返回 has_more=true, next_cursor="CURSOR_1"
- 第二次请求(传入上次的 next_cursor):
```bash
wecom-cli todo get_todo_list '{"limit": 20, "cursor": "CURSOR_1"}'
```
,返回 has_more=false,拉取完毕
**分页规则:**
- 首次请求不传 `cursor`
- `has_more``true` 时,将 `next_cursor` 作为下次请求的 `cursor` 传入
- `has_more``false` 时停止请求
- 分页过程中时间过滤参数保持不变
- **如果选择不继续翻页(比如当前页数据已经够用),必须告诉用户还有更多待办未显示,问用户是否需要继续查看**
---
## 注意事项
1. **时间格式**:所有时间参数使用 `YYYY-MM-DD HH:mm:ss` 格式,用户说"明天"、"下周一"等相对时间时,根据当前日期推算具体日期
2. **状态值含义**
- 待办状态(`todo_status`):`0`-已完成,`1`-进行中,`2`-已删除
- 用户状态(`user_status`):`0`-拒绝,`1`-接受,`2`-已完成
3. **错误处理**:若 `errcode` 不为 `0`,告知用户 `errmsg` 中的错误信息
4. **必须查详情**:本接口返回的是概要信息(不含内容和分派人),拿到列表后必须紧接着调用 `wecomcli-get-todo-detail` 获取完整内容再展示给用户,不要只展示列表概要
5. **分页未拉完必须提醒**:如果返回的 `has_more``true`,在向用户展示结果时必须明确说明"还有更多待办未显示"并询问用户是否需要继续查看。用户不知道后面还有数据,不提醒会导致遗漏
+178
View File
@@ -0,0 +1,178 @@
---
name: wecomcli-lookup-contact
description: 通讯录成员查询技能,获取当前用户可见范围内的通讯录成员,支持按姓名/别名本地筛选匹配。返回 userid、姓名和别名。⚠️ 仅返回当前用户有权限查看的成员,非全量成员。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli contact --help"
---
# 通讯录成员查询技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
获取当前用户可见范围内的通讯录成员,并在本地按姓名/别名进行筛选匹配。
## 操作
### 1. 获取全量通讯录成员
获取当前用户可见范围内的所有企业成员信息:
**调用示例:**
```bash
wecom-cli contact get_userlist '{}'
```
**返回格式:**
```json
{
"errcode": 0,
"errmsg": "ok",
"userlist": [
{
"userid": "zhangsan",
"name": "张三",
"alias": "Sam"
},
{
"userid": "lisi",
"name": "李四",
"alias": ""
}
]
}
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
| `userlist` | array | 用户列表 |
| `userlist[].userid` | string | 用户唯一 ID |
| `userlist[].name` | string | 用户姓名 |
| `userlist[].alias` | string | 用户别名,可能为空 |
---
### 2. 按姓名/别名搜索人员
`get_userlist` 返回全量成员后,在本地对结果进行筛选匹配:
- **精确匹配**`name``alias` 与关键词完全一致,直接使用
- **模糊匹配**`name``alias` 包含关键词,返回所有匹配结果
- **无结果**:告知用户未找到对应人员
**搜索示例:**
用户问:"帮我找一下张三是谁?"
1. 调用 `get_userlist` 获取全量成员
2.`userlist` 中筛选 `name``alias` 包含"张三"的成员
3. 返回匹配结果
---
## 注意事项
- `get_userlist` 返回的是当前用户**可见范围内**的成员,需经过可见性规则过滤,不一定是全公司所有人员;返回字段仅包含 `userid``name`(姓名)和 `alias`(别名)
- ⚠️ **超过 10 人时接口将报错**:若 `userlist` 返回成员数量超过 10 人,视为异常,应立即停止处理并向用户说明:
> 当前通讯录可见成员数量超过了本技能支持的上限(10 人)。
> 本技能仅适用于可见范围较小的场景,无法在大范围通讯录中使用。
> 建议缩小可见范围后重试,或通过其他方式查询目标人员。
- `userid` 是用户的唯一标识,在需要传递用户 ID 给其他接口时使用此字段
- `alias` 字段可能为空字符串,搜索时需做空值判断
- 若搜索结果有多个同名人员,需将所有候选人展示给用户选择,不得自行决定
-`errcode` 不为 `0`,说明接口调用失败,需告知用户错误信息(`errmsg`
---
## 典型工作流
### 工作流 1:查询人员信息
用户问:"帮我查一下 Sam 是谁?"
1.
```bash
wecom-cli contact get_userlist '{}'
```
获取全量成员列表
2. 在结果中筛选 `alias``Sam``name` 包含 `Sam` 的成员
3. 若找到唯一匹配,直接展示结果:
```
📇 找到成员:
- 姓名:张三
- 别名:Sam
- 用户IDzhangsan
```
4. 若找到多个匹配,展示候选列表请用户确认:
```
🔍 找到多个匹配成员,请确认您要查询的是哪位:
1. 张三(别名:SamIDzhangsan
2. 张三丰(别名:Sam2IDzhangsan2
请问您要查询的是哪一位?
```
---
### 工作流 2:为其他功能提供 userid 转换
用户问:"帮我发消息给张三"
1.
```bash
wecom-cli contact get_userlist '{}'
```
获取全量成员
2. 筛选 `name` 为"张三"的成员,确认 `userid`
3.`userid` 传递给消息发送接口
---
### 工作流 3:批量查询多个人员
用户问:"帮我查一下张三和李四分别是谁?"
1.
```bash
wecom-cli contact get_userlist '{}'
```
获取全量成员列表
2. 分别筛选"张三"和"李四"的匹配结果
3. 汇总后一并展示
> 注意:只需调用一次 `get_userlist`,在本地对结果进行多次筛选,避免重复调用接口。
---
## 快速参考
### 接口说明
| 接口 | 用途 | 输入 | 返回 |
|------|------|------|------|
| `get_userlist` | 获取可见范围内全量通讯录成员 | 无 | 用户列表(userid、name、alias |
### 本地筛选策略
| 场景 | 策略 |
|------|------|
| 精确匹配(name 或 alias 完全一致) | 直接使用,无需用户确认 |
| 模糊匹配(name 或 alias 包含关键词),唯一结果 | 直接使用,向用户展示结果 |
| 模糊匹配,多个结果 | 展示候选列表,请用户选择 |
| 无匹配结果 | 告知用户未找到对应人员 |
+94
View File
@@ -0,0 +1,94 @@
---
name: wecomcli-manage-doc
description: 企业微信文档管理技能。提供文档的创建、读取和编辑能力,支持通过 docid 或文档 URL 操作企业微信文档(doc_type=3)和智能表格(doc_type=10)。适用场景:(1) 以 Markdown 格式导出获取文档完整内容(异步轮询) (2) 新建文档或智能表格 (3) 用 Markdown 格式覆写文档内容。当用户需要查看文档内容、创建新文档、编辑文档正文时触发此 Skill。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli doc --help"
---
# 企业微信文档管理
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
管理企业微信文档的创建、读取和编辑。所有接口支持通过 `docid``url` 二选一定位文档。
## 调用方式
通过 `wecom-cli` 调用,品类为 `doc`
```bash
wecom-cli doc <tool_name> '<json_params>'
```
## 返回格式说明
所有接口返回 JSON 对象,包含以下公共字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功,非 `0` 表示失败 |
| `errmsg` | string | 错误信息,成功时为 `"ok"` |
`errcode` 不为 `0` 时,说明接口调用失败,可重试 1 次;若仍失败,将 `errcode``errmsg` 展示给用户。
### get_doc_content
获取文档完整内容数据,只能以 Markdown 格式返回。采用**异步轮询机制**:首次调用无需传 `task_id`,接口返回 `task_id`;若 `task_done` 为 false,需携带该 `task_id` 再次调用,直到 `task_done` 为 true 时返回完整内容。
- 首次调用(不传 task_id):
```bash
wecom-cli doc get_doc_content '{"docid": "DOCID", "type": 2}'
```
- 轮询(携带上次返回的 task_id):
```bash
wecom-cli doc get_doc_content '{"docid": "DOCID", "type": 2, "task_id": "xxx"}'
```
- 或通过 URL
```bash
wecom-cli doc get_doc_content '{"url": "https://doc.weixin.qq.com/doc/xxx", "type": 2}'
```
参见 [API 详情](references/api-export-document.md)。
### create_doc
新建文档(doc_type=3)或智能表格(doc_type=10)。创建成功返回 url 和 docid。
-
```bash
wecom-cli doc create_doc '{"doc_type": 3, "doc_name": "项目周报"}'
```
-
```bash
wecom-cli doc create_doc '{"doc_type": 10, "doc_name": "任务跟踪表"}'
```
**注意**:docid 仅在创建时返回,需妥善保存。创建智能表格时默认包含一个子表,可通过 `smartsheet_get_sheet` 查询其 sheet_id。
参见 [API 详情](references/api-create-doc.md)。
### edit_doc_content
用 Markdown 内容覆写文档正文。`content_type` 固定为 `1`Markdown)。
```bash
wecom-cli doc edit_doc_content '{"docid": "DOCID", "content": "# 标题\n\n正文内容", "content_type": 1}'
```
参见 [API 详情](references/api-edit-doc-content.md)。
## 典型工作流
1. **读取文档**
```bash
wecom-cli doc get_doc_content '{"docid": "DOCID", "type": 2}'
```
,若 `task_done` 为 false 则携带 `task_id` 继续轮询
2. **创建新文档**
```bash
wecom-cli doc create_doc '{"doc_type": 3, "doc_name": "文档名"}'
```
,保存返回的 docid
3. **编辑文档** → 先 get_doc_content 了解当前内容,再 edit_doc_content 覆写
@@ -0,0 +1,56 @@
# create_doc API
新建文档、表格或智能表格。创建成功后返回文档访问链接和 docid。
## 技能定义
```json
{
"name": "create_doc",
"description": "新建文档、表格或智能表格。支持在指定空间和目录下创建,可设置文档管理员。创建成功后返回文档访问链接和 docid(docid 仅在创建时返回,需妥善保存)。注意:创建智能表格(doc_type=10)时,文档会默认包含一个子表,可通过 smartsheet_get_sheet 查询其 sheet_id,无需额外调用 smartsheet_add_sheet。",
"inputSchema": {
"properties": {
"doc_type": {
"description": "文档类型:3-文档,10-智能表格",
"enum": [3, 10],
"title": "Doc Type",
"type": "integer"
},
"doc_name": {
"description": "文档名字,最多 255 个字符,超过会被截断",
"title": "Doc Name",
"type": "string"
}
},
"required": ["doc_type", "doc_name"],
"title": "create_docArguments",
"type": "object"
}
}
```
## 请求示例
```json
{
"doc_type": 3,
"doc_name": "项目周报"
}
```
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"url": "https://doc.weixin.qq.com/doc/xxx",
"docid": "DOCID"
}
```
## 注意事项
- `doc_type=3` 创建普通文档
- `doc_type=10` 创建智能表格,默认包含一个子表
- docid 仅在创建时返回,后续无法再获取,务必保存
@@ -0,0 +1,68 @@
# edit_doc_content API
编辑(覆写)文档内容。
## 技能定义
```json
{
"name": "edit_doc_content",
"description": "编辑文档内容",
"inputSchema": {
"properties": {
"docid": {
"description": "文档 id,与 url 二选一传入",
"title": "Docid",
"type": "string"
},
"url": {
"description": "文档的访问链接,与 docid 二选一传入",
"title": "URL",
"type": "string"
},
"content": {
"description": "覆写的文档内容",
"title": "Content",
"type": "string"
},
"content_type": {
"description": "内容类型格式。1:markdown",
"enum": [1],
"title": "Content Type",
"type": "integer"
}
},
"oneOf": [
{ "required": ["docid", "content", "content_type"] },
{ "required": ["url", "content", "content_type"] }
],
"title": "edit_doc_contentArguments",
"type": "object"
}
}
```
## 请求示例
```json
{
"docid": "DOCID",
"content": "# 标题\n\n正文内容",
"content_type": 1
}
```
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok"
}
```
## 注意事项
- `content_type` 当前仅支持 `1`Markdown 格式)
- 此操作为**覆写**,会替换文档全部内容
- 建议先调用 `get_document` 了解当前内容再编辑
@@ -0,0 +1,88 @@
# get_doc_content API
获取企业微信文档的完整内容数据,以 Markdown 格式返回。该接口采用异步轮询机制:首次调用无需传 task_id,接口会返回 task_id;若 task_done 为 false,需携带该 task_id 再次调用,直到 task_done 为 true 时返回完整内容。
## 技能定义
```json
{
"name": "get_doc_content",
"description": "获取企业微信文档的完整内容数据,以 Markdown 格式返回。该接口采用异步轮询机制:首次调用无需传 task_id,接口会返回 task_id;若 task_done 为 false,需携带该 task_id 再次调用,直到 task_done 为 true 时返回完整内容。",
"inputSchema": {
"properties": {
"docid": {
"description": "文档的 docid,与 url 二选一传入",
"title": "Doc ID",
"type": "string"
},
"url": {
"description": "文档的访问链接,与 docid 二选一传入",
"title": "URL",
"type": "string"
},
"type": {
"description": "内容返回格式。2: Markdown 格式",
"enum": [2],
"title": "Type",
"type": "integer"
},
"task_id": {
"description": "任务 ID,用于异步轮询。初次调用时不填,后续轮询时填写上次返回的 task_id",
"title": "Task ID",
"type": "string"
}
},
"oneOf": [
{ "required": ["docid", "type"] },
{ "required": ["url", "type"] }
],
"title": "get_doc_contentArguments",
"type": "object"
}
}
```
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| docid | string | 与 url 二选一 | 文档的 docid |
| url | string | 与 docid 二选一 | 文档的访问链接 |
| type | integer | 是 | 内容返回格式,固定传 `2`Markdown 格式) |
| task_id | string | 否 | 任务 ID,初次调用不填,后续轮询时填写上次返回的 task_id |
## 异步轮询机制
1. **首次调用**:传入 `docid`/`url``type: 2`,不传 `task_id`
2. **检查响应**:若 `task_done``false`,记录返回的 `task_id`
3. **轮询调用**:携带 `task_id` 再次调用,直到 `task_done``true`
4. **获取内容**:当 `task_done``true` 时,`content` 字段包含完整的 Markdown 内容
## 请求示例
```json
// 首次调用
{
"docid": "DOCID",
"type": 2
}
// 轮询调用
{
"docid": "DOCID",
"type": 2,
"task_id": "xxx"
}
```
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"content": "# 文档标题\n\n文档正文内容...",
"task_id": "xxxxx",
"task_done": true
}
```
+176
View File
@@ -0,0 +1,176 @@
---
name: wecomcli-manage-schedule
description: 企业微信日程管理技能。适用于用户对企业微信日程的各类管理需求。当用户需要:(1) 查询指定时间范围内的日程列表或获取日程详细信息(标题、时间、地点、参与者等),(2) 创建新日程并设置提醒、参与人等,(3) 修改已有日程的标题、时间、地点等信息或取消日程,(4) 添加或移除日程参与人,(5) 查询多个成员的闲忙状态并分析共同空闲时段以安排会议时使用此技能。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli schedule --help"
---
# 企业微信日程管理技能
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
通过 `wecom-cli schedule <接口名> '<json入参>'` 与企业微信日程系统交互。
## 注意事项
- 日程列表查询仅支持**当日前后 30 天**,时间格式 `YYYY-MM-DD``YYYY-MM-DD HH:MM:SS`
- 涉及参与者 userid 时,需先使用 **wecomcli-lookup-contact** 技能获取;存在同名时展示候选让用户选择(禁止暴露 userid)
- 创建/修改/取消前,先确认目标日程和参与者信息
- `errcode != 0` 时展示错误信息;返回的 `start_time`/`end_time` 为 Unix 时间戳(秒),需转为可读格式
- **注意时间格式转换**:接口入参使用字符串格式(如 `YYYY-MM-DD HH:MM:SS`),但返回值多为 Unix 时间戳,使用时需进行格式转换
---
## 接口列表
### get_schedule_list_by_range — 查询日程 ID 列表
```bash
wecom-cli schedule get_schedule_list_by_range '{"start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS"}'
```
返回 `schedule_id_list` 数组。仅支持当日前后 30 天。
### get_schedule_detail — 获取日程详情
```bash
wecom-cli schedule get_schedule_detail '{"schedule_id_list": ["SCHEDULE_ID_1", "SCHEDULE_ID_2"]}'
```
支持 1~50 个 ID,返回日程标题、时间、地点、参与者等。参见 [API 详情](references/api-get-schedule-detail.md)。
### create_schedule — 创建日程
```bash
wecom-cli schedule create_schedule '{"schedule": {"start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS", "summary": "日程标题", "attendees": [{"userid": "USER_ID"}], "reminders": {"is_remind": 1, "remind_before_event_secs": 3600, "timezone": 8}}}'
```
参见 [API 详情](references/api-create-schedule.md) | [reminders 字段](references/ref-reminders.md)。
### update_schedule — 修改日程
只需传入需修改的字段,未传字段保持不变。
```bash
wecom-cli schedule update_schedule '{"schedule": {"schedule_id": "SCHEDULE_ID", "summary": "更新后的标题"}}'
```
参见 [API 详情](references/api-update-schedule.md)。
### cancel_schedule — 取消日程
```bash
wecom-cli schedule cancel_schedule '{"schedule_id": "SCHEDULE_ID"}'
```
### add_schedule_attendees / del_schedule_attendees — 管理参与人
- 添加参与人:
```bash
wecom-cli schedule add_schedule_attendees '{"schedule_id": "SCHEDULE_ID", "attendees": [{"userid": "USER_ID"}]}'
```
- 移除参与人:
```bash
wecom-cli schedule del_schedule_attendees '{"schedule_id": "SCHEDULE_ID", "attendees": [{"userid": "USER_ID"}]}'
```
### check_availablity — 查询闲忙
```bash
wecom-cli schedule check_availablity '{"check_user_list": ["USER_ID_1", "USER_ID_2"], "start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS"}'
```
支持 1~10 个用户,返回各用户的忙碌时段列表。参见 [API 详情](references/api-check-availability.md)。
---
## 典型工作流
### 查询日程
**经典 query 示例:**
- "我今天有哪些日程?"
- "帮我看看这周三下午有没有会议"
- "明天的日程安排是什么?"
- "查一下最近有没有关于项目评审的日程"
- "我下周一到周五的日程都有哪些?"
**流程:**
1. 根据用户意图计算时间范围(如"今天"→当日 00:00:00 至 23:59:59"这周"→本周一至周日)
2. 调用 `get_schedule_list_by_range` 获取日程 ID 列表
3. 调用 `get_schedule_detail` 批量获取详情,将 Unix 时间戳转为可读时间
4. 若用户提到关键词(如"项目评审"),在 `summary` 中匹配筛选;未找到则逐步扩大范围至前后 30 天上限
5. 展示日程列表时包含标题、时间、地点、参与者等关键信息,方便用户快速了解
### 创建日程
**经典 query 示例:**
- "帮我创建一个明天下午 2 点到 3 点的会议,标题叫需求评审"
- "安排一个周五全天的团建活动"
- "创建日程:后天上午 10 点和张三、李四开产品方案讨论会,地点在 3 楼会议室"
- "帮我建个日程,下周一 14:00-15:00,提前 15 分钟提醒"
- "约一个明天上午的日程,邀请王伟参加"
**流程:**
1. 解析用户意图,提取时间、标题、地点、参与人、提醒设置等信息
2. 若涉及参与人,先通过 **wecomcli-lookup-contact** 查询 userid;存在同名时展示候选让用户选择
3. 若用户未指定提醒,默认设置提前 15 分钟提醒(`remind_before_event_secs: 900`
4. 若用户说"全天",设置 `is_whole_day: 1`,时间设为当天 00:00:00 至 23:59:59
5. 向用户确认日程信息(标题、时间、地点、参与人等)后调用 `create_schedule`
### 修改日程
**经典 query 示例:**
- "把明天的需求评审改到后天下午 3 点"
- "帮我修改下今天下午的会议标题,改成技术方案评审"
- "我今天 14 点的日程地点改成线上腾讯会议"
- "把周五的团建活动推迟一个小时"
- "帮我给明天的周会加个描述:讨论 Q2 规划"
**流程:**
1. 先通过查询工作流定位目标日程(根据用户提到的时间、标题等关键词匹配)
2. 若匹配到多个日程,展示候选列表让用户确认
3. 向用户确认要修改的字段和目标值
4. 调用 `update_schedule`,只传入需修改的字段
### 取消日程
**经典 query 示例:**
- "取消明天下午的需求评审"
- "帮我把周五的团建日程删掉"
- "我不想开今天 15 点的会了,帮我取消"
**流程:**
1. 先通过查询工作流定位目标日程
2. 向用户确认取消的日程信息(标题、时间等),避免误操作
3. 确认后调用 `cancel_schedule`
### 管理参与人
**经典 query 示例:**
- "把张三加到明天的需求评审会议里"
- "帮我把李四从周五的日程里移除"
- "明天下午的会议再邀请一下王伟和赵敏"
- "把我后天那个技术分享的参与人里去掉刘强"
**流程:**
1. 通过 **wecomcli-lookup-contact** 获取目标人员 userid;存在同名时展示候选让用户选择
2. 通过查询工作流定位目标日程
3. 调用 `add_schedule_attendees``del_schedule_attendees` 完成添加/移除
### 查询闲忙并安排会议
**经典 query 示例:**
- "帮我看看张三和李四明天下午有没有空"
- "查一下我和王伟这周的空闲时间,想约个会"
- "我想跟产品组的小明、小红开个会,看看大家什么时候有空"
- "找一个明天下午大家都有空的时段,安排一个 1 小时的会议"
**流程:**
1. 通过 **wecomcli-lookup-contact** 获取相关人员 userid
2. 调用 `check_availablity` 查询指定时间范围内各用户的忙碌时段
3. 分析所有用户的忙碌时段,计算出共同空闲时段并推荐给用户
4. 用户确认时段后,调用 `create_schedule` 创建会议并自动添加参与人
@@ -0,0 +1,58 @@
# check_availablity API
查询指定用户在某时间范围内的忙碌时段。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `check_user_list` | array | ✅ | 用户 ID 列表,1~10 个 |
| `start_time` | string | ✅ | 查询开始时间 |
| `end_time` | string | ✅ | 查询结束时间 |
## 请求示例
```bash
wecom-cli schedule check_availablity '{"check_user_list": ["USER_ID_1", "USER_ID_2"], "start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS"}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
| `user_busy_list` | array | 用户忙碌时段列表 |
### user_busy_list[] 数组中每项字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `userid` | string | 用户 ID |
| `busy_slots` | array | 忙碌时段列表 |
| `busy_slots[].start_time` | string | 忙碌时段开始时间 |
| `busy_slots[].end_time` | string | 忙碌时段结束时间 |
| `busy_slots[].schedule_id` | string | 关联的日程 ID |
| `busy_slots[].subject` | string | 日程标题 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"user_busy_list": [
{
"userid": "USER_ID",
"busy_slots": [
{
"start_time": "YYYY-MM-DD HH:MM:SS",
"end_time": "YYYY-MM-DD HH:MM:SS",
"schedule_id": "SCHEDULE_ID",
"subject": "日程标题"
}
]
}
]
}
```
@@ -0,0 +1,40 @@
# create_schedule API
创建新日程,支持设置标题、时间、地点、参与者、提醒和重复规则。
## 参数说明(`schedule` 对象内)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `start_time` | string | ✅ | 开始时间 |
| `end_time` | string | ✅ | 结束时间 |
| `summary` | string | ❌ | 日程标题,最长 128 字 |
| `description` | string | ❌ | 日程描述,最长 1000 字 |
| `location` | string | ❌ | 地点,最长 128 字 |
| `is_whole_day` | integer | ❌ | 是否全天:`0`-否(默认),`1`-是 |
| `attendees` | array | ❌ | 参与者列表,每项含 `userid` |
| `reminders` | object | ❌ | 提醒与重复设置(见 [reminders 字段参考](ref-reminders.md) |
## 请求示例
```bash
wecom-cli schedule create_schedule '{"schedule": {"start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS", "summary": "日程标题", "attendees": [{"userid": "USER_ID"}], "reminders": {"is_remind": 1, "remind_before_event_secs": 3600, "timezone": 8}, "location": "会议地点"}}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
| `schedule_id` | string | 创建成功的日程 ID |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"schedule_id": "SCHEDULE_ID"
}
```
@@ -0,0 +1,83 @@
# get_schedule_detail API
通过日程 ID 批量获取日程详细信息。
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `schedule_id_list` | array | ✅ | 日程 ID 列表,1~50 个 |
## 请求示例
```bash
wecom-cli schedule get_schedule_detail '{"schedule_id_list": ["SCHEDULE_ID_1", "SCHEDULE_ID_2"]}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
| `schedule` | array | 日程详情列表 |
### schedule[] 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `schedule_id` | string | 日程唯一 ID |
| `summary` | string | 日程标题 |
| `description` | string | 日程描述 |
| `start_time` | integer | 开始时间(Unix 时间戳,秒) |
| `end_time` | integer | 结束时间(Unix 时间戳,秒) |
| `location` | string | 地点 |
| `status` | integer | `0`-正常,`1`-已取消 |
| `is_whole_day` | integer | `0`-否,`1`-是 |
| `admins` | array | 管理员 userid 列表 |
| `attendees` | array | 参与者列表 |
| `attendees[].userid` | string | 参与者 userid |
| `attendees[].response_status` | integer | 响应状态(见下表) |
| `reminders` | object | 提醒设置(见 [reminders 字段参考](ref-reminders.md) |
### response_status 枚举
| 值 | 含义 |
|----|------|
| `1` | 待定 |
| `2` | 接受 |
| `3` | 接受单次 |
| `4` | 拒绝 |
| `5` | 接受本次及未来 |
| `6` | 待定单次 |
| `7` | 待定本次及未来 |
| `8` | 拒绝单次 |
| `9` | 拒绝本次及未来 |
## 响应示例
```json
{
"errcode": 0,
"errmsg": "ok",
"schedule": [
{
"schedule_id": "SCHEDULE_ID",
"summary": "日程标题",
"start_time": 1700000000,
"end_time": 1700003600,
"location": "会议室",
"status": 0,
"is_whole_day": 0,
"attendees": [
{"userid": "USER_ID","tmp_external_userid": "tmp_external_userid_example","response_status": 2}
],
"reminders": {
"is_remind": 1,
"remind_before_event_secs": 3600,
"timezone": 8
}
}
]
}
```
@@ -0,0 +1,32 @@
# update_schedule API
修改已有日程,只需传入需要修改的字段,未传字段保持不变。
## 参数说明(`schedule` 对象内)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `schedule_id` | string | ✅ | 目标日程 ID |
| `start_time` | string | ❌ | 开始时间 |
| `end_time` | string | ❌ | 结束时间 |
| `summary` | string | ❌ | 日程标题,最长 128 字 |
| `description` | string | ❌ | 日程描述,最长 1000 字 |
| `location` | string | ❌ | 地点,最长 128 字 |
| `is_whole_day` | integer | ❌ | 是否全天:`0`-否,`1`-是 |
| `attendees` | array | ❌ | 参与者列表,每项含 `userid` |
| `reminders` | object | ❌ | 提醒与重复设置(见 [reminders 字段参考](ref-reminders.md) |
> 仅传需修改的字段,其余保持不变。
## 请求示例
```bash
wecom-cli schedule update_schedule '{"schedule": {"schedule_id": "SCHEDULE_ID", "summary": "更新后的标题", "start_time": "YYYY-MM-DD HH:MM:SS", "end_time": "YYYY-MM-DD HH:MM:SS"}}'
```
## 返回字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功 |
| `errmsg` | string | 错误信息 |
@@ -0,0 +1,24 @@
# reminders 字段参考
提醒设置对象,用于 `create_schedule``update_schedule` 接口。
## 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `is_remind` | integer | 是否提醒:`0`-否,`1`-是 |
| `remind_before_event_secs` | integer | 提前提醒秒数,可选值:`0`/`300`/`900`/`3600`/`86400` |
| `remind_time_diffs` | array | 提醒时间差(秒),可选值:`-604800`/`-172800`/`-86400`/`-3600`/`-900`/`-300`/`0`/`32400` |
| `timezone` | integer | 时区,`-12` ~ `12`,中国为 `8` |
## 使用示例
### 基本提醒(提前 1 小时)
```json
{
"is_remind": 1,
"remind_before_event_secs": 3600,
"timezone": 8
}
```
@@ -0,0 +1,90 @@
---
name: wecomcli-manage-smartsheet-data
description: 企业微信智能表格数据(记录)管理技能。提供智能表格记录的增删改查能力。适用场景:(1) 查询子表全部记录 (2) 添加一行或多行记录 (3) 更新已有记录 (4) 删除记录。当用户需要读取表格数据、写入新数据、修改或删除表格行时触发此 Skill。支持通过 docid 或文档 URL 定位文档。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli doc --help"
---
# 企业微信智能表格数据管理
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
管理智能表格中的记录(行数据)。所有接口支持通过 `docid``url` 二选一定位文档。
## CLI 调用方式
通过 `wecom-cli` 调用,品类为 `doc`
```bash
wecom-cli doc <tool_name> '<json_params>'
```
## 返回格式说明
所有接口返回 JSON 对象,包含以下公共字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功,非 `0` 表示失败 |
| `errmsg` | string | 错误信息,成功时为 `"ok"` |
`errcode` 不为 `0` 时,说明接口调用失败,可重试 1 次;若仍失败,将 `errcode``errmsg` 展示给用户。
### smartsheet_get_records
查询子表全部记录。
-
```bash
wecom-cli doc smartsheet_get_records '{"docid": "DOCID", "sheet_id": "SHEETID"}'
```
- 或通过 URL
```bash
wecom-cli doc smartsheet_get_records '{"url": "https://doc.weixin.qq.com/smartsheet/xxx", "sheet_id": "SHEETID"}'
```
参见 [API 详情](references/api-get-records.md)。
### smartsheet_add_records
添加一行或多行记录,单次建议 500 行内。
**调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`)。
```bash
wecom-cli doc smartsheet_add_records '{"docid": "DOCID", "sheet_id": "SHEETID", "records": [{"values": {"任务名称": [{"type": "text", "text": "完成需求文档"}], "优先级": [{"text": "高"}]}}]}'
```
各字段类型的值格式参见 [单元格值格式参考](references/cell-value-formats.md)。
### smartsheet_update_records
更新一行或多行记录,单次必须在 500 行内。需提供 record_id(通过 `smartsheet_get_records` 获取)。
```bash
wecom-cli doc smartsheet_update_records '{"docid": "DOCID", "sheet_id": "SHEETID", "records": [{"record_id": "RECORDID", "values": {"任务名称": [{"type": "text", "text": "更新后的内容"}]}}]}'
```
**注意**:创建时间、最后编辑时间、创建人、最后编辑人字段不可更新。
### smartsheet_delete_records
删除一行或多行记录,单次必须在 500 行内。**操作不可逆**。record_id 通过 `smartsheet_get_records` 获取。
```bash
wecom-cli doc smartsheet_delete_records '{"docid": "DOCID", "sheet_id": "SHEETID", "record_ids": ["RECORDID1", "RECORDID2"]}'
```
## 典型工作流
1. **读取数据**
```bash
wecom-cli doc smartsheet_get_records '{"docid":"DOCID","sheet_id":"SHEETID"}'
```
2. **写入数据** → 先 `smartsheet_get_fields` 了解列类型 → 若涉及成员(USER)字段,先通过 `wecomcli-lookup-contact``get_userlist` 查找人员 userid → `smartsheet_add_records` 写入
3. **更新数据** → 先 `smartsheet_get_records` 获取 record_id → 若涉及成员(USER)字段,先通过 `wecomcli-lookup-contact``get_userlist` 查找人员 userid → `smartsheet_update_records` 更新
4. **删除数据** → 先 `smartsheet_get_records` 确认 record_id → `smartsheet_delete_records` 删除
> **注意**:成员(USER)类型字段需要填写 `user_id`,不能直接使用姓名。必须先通过 `wecomcli-lookup-contact` 技能的 `get_userlist` 接口按姓名查找到对应的 `userid` 后再使用。
@@ -0,0 +1,61 @@
# smartsheet_get_records API
查询智能表格中指定子表的记录信息。只支持获取全部记录。支持通过 docid 或文档 URL 定位文档,二者传入其一即可。
## 技能定义
```json
{
"name": "smartsheet_get_records",
"description": "查询智能表格中指定子表的记录信息。只支持获取全部记录。支持通过 docid 或文档 URL 定位文档,二者传入其一即可。",
"inputSchema": {
"properties": {
"docid": {
"description": "文档的 docid,与 url 二选一传入",
"title": "Docid",
"type": "string"
},
"url": {
"description": "文档的访问链接,与 docid 二选一传入",
"title": "URL",
"type": "string"
},
"sheet_id": {
"description": "子表的 sheet_id,用于指定要查询的智能表格中的哪个子表",
"title": "Sheet Id",
"type": "string"
}
},
"oneOf": [
{ "required": ["docid", "sheet_id"] },
{ "required": ["url", "sheet_id"] }
],
"title": "smartsheet_get_recordsArguments",
"type": "object"
}
}
```
## 参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| docid | string | 与 url 二选一 | 文档的 docid |
| url | string | 与 docid 二选一 | 文档的访问链接 |
| sheet_id | string | 是 | 子表的 sheet_id,用于指定要查询的智能表格中的哪个子表 |
## 请求示例
```json
{
"docid": "DOCID",
"sheet_id": "123Abc"
}
```
```json
{
"url": "https://doc.weixin.qq.com/smartsheet/xxx",
"sheet_id": "123Abc"
}
```
@@ -0,0 +1,124 @@
# 单元格值格式参考
`smartsheet_add_records``smartsheet_update_records` 中,`values` 的 key **必须是字段标题(field_title**,不能使用字段 ID。
## 各字段类型的值格式
### 1. 文本 (FIELD_TYPE_TEXT)
**必须**使用数组格式,外层方括号不可省略:
```json
"字段标题": [{"type": "text", "text": "内容"}]
```
### 2. 数字 (NUMBER) / 货币 (CURRENCY) / 百分比 (PERCENTAGE) / 进度 (PROGRESS)
直接传数字:
```json
"金额": 100,
"完成率": 0.6,
"进度": 80
```
### 3. 复选框 (CHECKBOX)
直接传布尔值:
```json
"已完成": true
```
### 4. 单选 (SINGLE_SELECT) / 多选 (SELECT)
**必须**使用数组格式,不能直接传字符串:
```json
"优先级": [{"text": "高"}],
"标签": [{"text": "紧急"}, {"text": "重要"}]
```
选项可附带 `id`(已存在选项)和 `style`(颜色 1-27)。
### 5. 日期时间 (DATE_TIME)
传日期时间字符串,系统自动按东八区转换:
```json
"截止日期": "2026-01-15 14:30:00",
"创建日期": "2026-01-15"
```
支持格式:`YYYY-MM-DD HH:MM:SS``YYYY-MM-DD HH:MM``YYYY-MM-DD`
### 6. 手机号 (PHONE_NUMBER) / 邮箱 (EMAIL) / 条码 (BARCODE)
直接传字符串:
```json
"电话": "13800138000",
"邮箱": "test@example.com"
```
### 7. 成员 (USER)
数组格式,需传 user_id。**user_id 不是姓名**,必须先通过 `wecomcli-lookup-contact` 技能查找目标人员的 `userid`,再填入此处。
具体步骤:先
```bash
wecom-cli contact get_userlist '{}'
```
获取通讯录成员列表,在返回结果中按姓名/别名筛选出目标人员,取其 `userid` 值填入。
```json
"负责人": [{"user_id": "zhangsan"}]
```
多个成员:
```json
"负责人": [{"user_id": "zhangsan"}, {"user_id": "lisi"}]
```
### 8. 超链接 (URL)
数组格式,目前仅支持一个链接:
```json
"参考链接": [{"type": "url", "text": "官网", "link": "https://example.com"}]
```
### 9. 图片 (IMAGE)
数组格式:
```json
"封面": [{"image_url": "https://example.com/img.png"}]
```
### 10. 地理位置 (LOCATION)
数组格式:
```json
"地点": [{"source_type": 1, "id": "地点ID", "latitude": "39.9", "longitude": "116.3", "title": "北京"}]
```
## 完整添加记录示例
```json
{
"docid": "DOCID",
"sheet_id": "SHEETID",
"records": [{
"values": {
"任务名称": [{"type": "text", "text": "完成需求文档"}],
"优先级": [{"text": "高"}],
"截止日期": "2026-03-20",
"完成进度": 30,
"已完成": false
}
}]
}
```
@@ -0,0 +1,119 @@
---
name: wecomcli-manage-smartsheet-schema
description: 企业微信智能表格结构管理技能。提供子表(Sheet)和字段(Field/列)的增删改查能力。适用场景:(1) 查询智能表格的子表列表 (2) 添加、更新、删除子表 (3) 查询子表的字段/列信息 (4) 添加、更新、删除字段/列。当用户需要管理智能表格的表结构、列定义、子表配置时触发此 Skill。支持通过 docid 或文档 URL 定位文档。
metadata:
requires:
bins: ["wecom-cli"]
cliHelp: "wecom-cli doc --help"
---
# 企业微信智能表格结构管理
> `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。
管理智能表格的子表和字段(列)结构。所有接口支持通过 `docid``url` 二选一定位文档。
## 调用方式
通过 `wecom-cli` 调用,品类为 `doc`
```bash
wecom-cli doc <tool_name> '<json_params>'
```
## 返回格式说明
所有接口返回 JSON 对象,包含以下公共字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `errcode` | integer | 返回码,`0` 表示成功,非 `0` 表示失败 |
| `errmsg` | string | 错误信息,成功时为 `"ok"` |
`errcode` 不为 `0` 时,说明接口调用失败,可重试 1 次;若仍失败,将 `errcode``errmsg` 展示给用户。
## 子表管理
### smartsheet_get_sheet
查询文档中所有子表信息,返回 sheet_id、title、类型等。
```bash
wecom-cli doc smartsheet_get_sheet '{"docid": "DOCID"}'
```
### smartsheet_add_sheet
添加空子表。新子表不含视图、记录和字段,需通过其他接口补充。
```bash
wecom-cli doc smartsheet_add_sheet '{"docid": "DOCID", "properties": {"title": "新子表"}}'
```
**注意**:新建智能表格文档默认已含一个子表,仅需多个子表时调用。
### smartsheet_update_sheet
修改子表标题。需提供 sheet_id 和新 title。
```bash
wecom-cli doc smartsheet_update_sheet '{"docid": "DOCID", "properties":{"sheet_id":"SHEET_ID", "title":"新子表"}}'
```
### smartsheet_delete_sheet
永久删除子表,**操作不可逆**。
```bash
wecom-cli doc smartsheet_delete_sheet '{"docid": "DOCID", "sheet_id": "SHEETID"}'
```
## 字段管理
### smartsheet_get_fields
查询子表的所有字段信息,返回 field_id、field_title、field_type。
```bash
wecom-cli doc smartsheet_get_fields '{"docid": "DOCID", "sheet_id": "SHEETID"}'
```
### smartsheet_add_fields
向子表添加一个或多个字段。单个子表最多 150 个字段。
```bash
wecom-cli doc smartsheet_add_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "fields": [{"field_title": "任务名称", "field_type": "FIELD_TYPE_TEXT"}]}'
```
支持的字段类型参见 [字段类型参考](references/field-types.md)。
### smartsheet_update_fields
更新字段标题。**只能改名,不能改类型**(field_type 必须传原始类型)。field_title 不能更新为原值。
```bash
wecom-cli doc smartsheet_update_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "fields": [{"field_id": "FIELDID", "field_title": "新标题", "field_type": "FIELD_TYPE_TEXT"}]}'
```
### smartsheet_delete_fields
删除一列或多列字段,**操作不可逆**。field_id 可通过 `smartsheet_get_fields` 获取。
```bash
wecom-cli doc smartsheet_delete_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "field_ids": ["FIELDID"]}'
```
## 典型工作流
1. **了解表结构**
```bash
wecom-cli doc smartsheet_get_sheet
```
```bash
wecom-cli doc smartsheet_get_fields
```
2. **创建表结构**`smartsheet_add_sheet` 添加子表 → `smartsheet_add_fields` 定义列
3. **修改表结构**`smartsheet_update_fields` 改列名 / `smartsheet_delete_fields` 删列
@@ -0,0 +1,43 @@
# 智能表格字段类型参考
## 支持的字段类型
| 类型枚举值 | 说明 | 适用场景 |
|---|---|---|
| `FIELD_TYPE_TEXT` | 文本 | 名称、标题、描述、负责人姓名等自由文本 |
| `FIELD_TYPE_NUMBER` | 数字 | 金额、工时、数量等数值 |
| `FIELD_TYPE_CHECKBOX` | 复选框 | 是否完成等布尔值 |
| `FIELD_TYPE_DATE_TIME` | 日期时间 | 截止日期、创建时间等 |
| `FIELD_TYPE_IMAGE` | 图片 | 附件图片 |
| `FIELD_TYPE_USER` | 用户/成员 | 需传入 user_id;仅在明确知道成员 ID 时使用,若只有姓名应用 TEXT |
| `FIELD_TYPE_URL` | 链接 | 超链接 |
| `FIELD_TYPE_SELECT` | 多选 | 标签、分类等可多选的选项 |
| `FIELD_TYPE_PROGRESS` | 进度 | 完成进度(0-100 整数) |
| `FIELD_TYPE_PHONE_NUMBER` | 手机号 | 联系电话 |
| `FIELD_TYPE_EMAIL` | 邮箱 | 电子邮件 |
| `FIELD_TYPE_SINGLE_SELECT` | 单选 | 状态、优先级、严重程度等有固定选项的字段 |
| `FIELD_TYPE_LOCATION` | 位置 | 地理位置 |
| `FIELD_TYPE_CURRENCY` | 货币 | 货币金额 |
| `FIELD_TYPE_PERCENTAGE` | 百分比 | 比率类数值(完成率、转化率) |
| `FIELD_TYPE_BARCODE` | 条码 | 条形码/二维码 |
## 添加字段示例
```json
{
"docid": "DOCID",
"sheet_id": "SHEETID",
"fields": [
{ "field_title": "任务名称", "field_type": "FIELD_TYPE_TEXT" },
{ "field_title": "优先级", "field_type": "FIELD_TYPE_SINGLE_SELECT" },
{ "field_title": "截止日期", "field_type": "FIELD_TYPE_DATE_TIME" },
{ "field_title": "完成进度", "field_type": "FIELD_TYPE_PROGRESS" }
]
}
```
## 更新字段注意事项
- `smartsheet_update_fields` **只能更新字段标题**,不能更改字段类型
- `field_type` 必须传字段当前的原始类型
- `field_title` 不能更新为原值(即不能传与当前相同的标题)
+53
View File
@@ -0,0 +1,53 @@
mod protocol;
use std::fs;
use crate::{crypto, fs_util};
use anyhow::Result;
pub use protocol::Bot;
/// Read encrypted bot info from disk, decrypt and return it.
/// Returns `None` if the file does not exist or decryption fails.
pub fn get_bot_info() -> Option<Bot> {
let data = fs::read(bot_info_path()).ok()?;
crypto::try_decrypt_data(&data).ok()
}
/// Serialize bot info, encrypt and persist to disk.
/// The encryption key is stored in the system keyring when possible, otherwise falls back to an encrypted file.
pub fn set_bot_info(bot: &Bot) -> Result<()> {
// 1. Load or generate an encryption key
let key = crypto::load_existing_key().unwrap_or_else(|| {
let k = crypto::generate_random_key();
tracing::info!("已生成新的加密密钥");
k
});
// 2. Persist the key (prefer keyring, fall back to file)
crypto::save_key(&key)?;
// 3. Serialize bot info → JSON → encrypt
let encrypted = crypto::encrypt_data(bot, &key)?;
// 4. Write to file
let path = bot_info_path();
fs_util::atomic_write(&path, &encrypted, Some(0o600))?;
tracing::info!("企业微信机器人信息已保存到 {}", path.display());
Ok(())
}
/// Remove the stored Bot info from disk.
pub fn clear_bot_info() {
let path = bot_info_path();
if path.exists() {
let _ = fs::remove_file(&path);
tracing::info!("机器人信息已删除:{}", path.display());
}
}
/// Return the file path for the encrypted bot credentials.
fn bot_info_path() -> std::path::PathBuf {
crate::constants::config_dir().join("bot.enc")
}
+28
View File
@@ -0,0 +1,28 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bot {
// Bot ID
pub id: String,
// Bot Secret
pub secret: String,
// Creation timestamp (unix epoch seconds)
pub create_time: u64,
}
impl Bot {
/// Create a new Bot with `create_time` set to the current timestamp.
pub fn new(id: String, secret: String) -> Self {
let create_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
id,
secret,
create_time,
}
}
}
+84
View File
@@ -0,0 +1,84 @@
use crate::{config, help, json_rpc, media};
use anyhow::Result;
use clap::{ArgMatches, Args, FromArgMatches};
use serde_json::json;
#[derive(Args)]
pub struct CallArgs {
/// 要调用的工具方法名
#[arg(value_name = "method")]
pub method: Option<String>,
/// JSON 格式的参数
#[arg(value_name = "args")]
pub args: Option<String>,
#[arg(long, short)]
pub help: bool,
}
/// Handle the `call` subcommand: dispatch a JSON-RPC tool invocation for a given category and method.
pub async fn handle_call_cmd(category_name: &str, matches: &ArgMatches) -> Result<()> {
let args = CallArgs::from_arg_matches(matches)?;
// Check if the category is valid
let categories = config::get_categories();
if !categories.iter().any(|c| c.name == category_name) {
anyhow::bail!("无效命令:{}", category_name);
}
if args.help {
if let Some(method) = args.method.as_deref() {
help::show_tool_help(category_name, method).await?;
} else {
help::show_category_tools(category_name).await?;
}
return Ok(());
}
// Get positional arg: method
let Some(method) = args.method.as_deref() else {
// No method provided, show category tools list
help::show_category_tools(category_name).await?;
return Ok(());
};
// Get positional arg: json_args (optional)
let args = args.args.as_deref();
// If no arguments provided, show tool help information
if args.is_none() {
help::show_tool_help(category_name, method).await?;
return Ok(());
}
let timeout_ms = if method == "get_msg_media" {
Some(120000)
} else {
None
};
let parsed_args = if let Some(args) = args {
serde_json::from_str(args)?
} else {
json!({})
};
let params = json!({
"name": method,
"arguments": parsed_args,
});
let mut res = json_rpc::send(category_name, "tools/call", Some(params), timeout_ms).await?;
if method == "get_msg_media" {
res = media::intercept_media_response(res).await?;
}
if let Some(result) = res.get("result") {
println!("{}", result);
}
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
use crate::auth;
use crate::mcp;
use anyhow::Result;
use clap::ArgMatches;
use clap::Args;
use clap::FromArgMatches;
#[derive(Args)]
pub struct InitArgs {
#[arg(long, help = "企业微信机器人 Bot ID")]
bot_id: Option<String>,
#[arg(long, help = "仅刷新 MCP 后台配置")]
refresh: bool,
}
/// Handle the `init` subcommand: prompt for bot credentials, persist them, and verify via MCP config fetch.
pub async fn handle_init_cmd(matches: &ArgMatches) -> Result<()> {
let args = InitArgs::from_arg_matches(matches)?;
if args.refresh {
mcp::config::fetch_mcp_config().await?;
println!("MCP 后台配置刷新成功");
return Ok(());
}
cliclack::intro("企业微信机器人初始化")?;
let bot_id: String = match args.bot_id {
Some(id) => id,
None => cliclack::input("企业微信机器人 Bot ID")
.placeholder("请输入企业微信机器人ID")
.interact()?,
};
let bot_secret: String = cliclack::password("企业微信机器人 Secret")
.mask('*')
.interact()?;
let bot = auth::Bot::new(bot_id, bot_secret);
auth::set_bot_info(&bot)?;
// Verify credentials by fetching MCP config from server
let spinner = cliclack::spinner();
spinner.start("正在验证企业微信机器人凭证...");
match mcp::config::fetch_mcp_config().await {
Ok(_) => {
spinner.stop("企业微信机器人凭证验证成功");
cliclack::outro("初始化完成 ✅")?;
Ok(())
}
Err(e) => {
spinner.stop("企业微信机器人凭证验证失败");
// Credentials invalid or server unreachable — rollback
auth::clear_bot_info();
mcp::config::clear_mcp_config();
cliclack::outro("初始化失败 ❌")?;
anyhow::bail!("验证企业微信机器人凭证失败: {e}");
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod call;
pub mod init;
+43
View File
@@ -0,0 +1,43 @@
#[derive(Debug, Clone)]
pub struct CategoryInfo {
pub name: &'static str,
pub description: &'static str,
}
/// Return all supported business categories and their tool definitions.
pub fn get_categories() -> Vec<CategoryInfo> {
let mut categories = vec![];
// Insert categories in alphabetical order
categories.push(CategoryInfo {
name: "contact",
description: "通讯录 — 成员查询和搜索",
});
categories.push(CategoryInfo {
name: "doc",
description: "文档 — 文档/智能表格创建和管理",
});
categories.push(CategoryInfo {
name: "meeting",
description: "会议 — 创建/管理/查询视频会议",
});
categories.push(CategoryInfo {
name: "msg",
description: "消息 — 聊天列表、发送/接收消息、媒体下载",
});
categories.push(CategoryInfo {
name: "schedule",
description: "日程 — 日程增删改查和可用性查询",
});
categories.push(CategoryInfo {
name: "todo",
description: "待办事项 — 创建/查询/编辑待办项",
});
categories
}
+46
View File
@@ -0,0 +1,46 @@
use std::path::PathBuf;
const DEFAULT_MCP_CONFIG_ENDPOINT: &str =
"https://qyapi.weixin.qq.com/cgi-bin/aibot/cli/get_mcp_config";
pub mod env {
/// Config directory, defaults to ~/.config/wecom
pub const CONFIG_DIR: &str = "WECOM_CLI_CONFIG_DIR";
/// Temp directory, defaults to std::env::temp_dir().join("wecom")
pub const TMP_DIR: &str = "WECOM_CLI_TMP_DIR";
/// Log level
pub const LOG_LEVEL: &str = "WECOM_CLI_LOG_LEVEL";
/// Log file directory path
pub const LOG_FILE: &str = "WECOM_CLI_LOG_FILE";
/// MCP config URL
pub const MCP_CONFIG_ENDPOINT: &str = "WECOM_CLI_MCP_CONFIG_ENDPOINT";
}
/// Return the configuration directory path (env override or `~/.config/wecom`).
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var(env::CONFIG_DIR) {
return PathBuf::from(dir);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("wecom")
}
/// Return the media temp directory path (env override or `<tmp>/wecom/media`).
pub fn media_dir() -> PathBuf {
if let Ok(dir) = std::env::var(env::TMP_DIR) {
return PathBuf::from(dir).join("media");
}
std::env::temp_dir().join("wecom").join("media")
}
/// Return the MCP config endpoint URL (env override or the default WeCom API).
pub fn mcp_config_endpoint() -> String {
std::env::var(env::MCP_CONFIG_ENDPOINT)
.unwrap_or_else(|_| DEFAULT_MCP_CONFIG_ENDPOINT.to_string())
}
+118
View File
@@ -0,0 +1,118 @@
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
use anyhow::Result;
/// AES-GCM nonce size (96 bits).
const NONCE_SIZE: usize = 12;
/// AES-GCM authentication tag size (128 bits).
const TAG_SIZE: usize = 16;
/// Encrypt `plaintext` with AES-256-GCM. Returns `nonce || ciphertext`.
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| anyhow::anyhow!("数据加密失败:{e}"))?;
let mut out = nonce.to_vec();
out.extend(ciphertext);
Ok(out)
}
/// Decrypt `data` (expected format: `nonce || ciphertext || tag`) with AES-256-GCM.
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < NONCE_SIZE + TAG_SIZE {
return Err(anyhow::anyhow!("数据解密失败(数据可能已损坏或被截断)",));
}
let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("数据解密失败:{e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::keystore::generate_random_key;
#[test]
fn encrypt_decrypt_roundtrip() {
let key = generate_random_key();
let plaintext = b"hello, AES-256-GCM!";
let encrypted = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn encrypt_decrypt_empty_plaintext() {
let key = generate_random_key();
let encrypted = encrypt(&key, b"").unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, b"");
}
#[test]
fn encrypted_output_has_expected_length() {
let key = generate_random_key();
let plaintext = b"test data";
let encrypted = encrypt(&key, plaintext).unwrap();
// nonce (12) + plaintext (9) + tag (16) = 37
assert_eq!(encrypted.len(), NONCE_SIZE + plaintext.len() + TAG_SIZE);
}
#[test]
fn decrypt_with_wrong_key_fails() {
let key1 = generate_random_key();
let key2 = generate_random_key();
let encrypted = encrypt(&key1, b"secret").unwrap();
assert!(decrypt(&key2, &encrypted).is_err());
}
#[test]
fn decrypt_too_short_data_fails() {
let key = generate_random_key();
// Less than NONCE_SIZE + TAG_SIZE = 28
assert!(decrypt(&key, &[0u8; 27]).is_err());
assert!(decrypt(&key, &[]).is_err());
assert!(decrypt(&key, &[0u8; 11]).is_err());
}
#[test]
fn decrypt_corrupted_data_fails() {
let key = generate_random_key();
let encrypted = encrypt(&key, b"important data").unwrap();
// Flip a byte in the ciphertext portion
let mut corrupted = encrypted.clone();
let last = corrupted.len() - 1;
corrupted[last] ^= 0xFF;
assert!(decrypt(&key, &corrupted).is_err());
}
#[test]
fn each_encryption_produces_different_output() {
let key = generate_random_key();
let plaintext = b"same plaintext";
let a = encrypt(&key, plaintext).unwrap();
let b = encrypt(&key, plaintext).unwrap();
// Different nonces → different ciphertext
assert_ne!(a, b);
// But both decrypt to the same plaintext
assert_eq!(decrypt(&key, &a).unwrap(), plaintext);
assert_eq!(decrypt(&key, &b).unwrap(), plaintext);
}
}
+251
View File
@@ -0,0 +1,251 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use base64::prelude::*;
use rand::Rng;
use crate::fs_util;
use super::cipher;
const KEYRING_SERVICE: &str = "wecom-cli";
const KEYRING_USER: &str = "encryption-key";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
/// Return the file path for the local encryption key fallback.
pub fn encryption_key_path() -> PathBuf {
crate::constants::config_dir().join(".encryption_key")
}
// ---------------------------------------------------------------------------
// Encode / Decode
// ---------------------------------------------------------------------------
/// Encode a 32-byte key as a Base64 string.
fn encode_key(key: &[u8; 32]) -> String {
BASE64_STANDARD.encode(key)
}
/// Decode a Base64 string into a 32-byte key, returning an error on invalid input.
fn decode_key(s: &str) -> Result<[u8; 32]> {
let bytes = BASE64_STANDARD
.decode(s)
.map_err(|e| anyhow::anyhow!("base64 decode error: {e}"))?;
<[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| anyhow::anyhow!("Invalid encryption key length"))
}
// ---------------------------------------------------------------------------
// Key generation / loading / saving
// ---------------------------------------------------------------------------
/// Generate a fresh random 256-bit key.
pub fn generate_random_key() -> [u8; 32] {
rand::rng().random()
}
/// Load the key from keyring. Returns `None` if unavailable.
fn load_key_from_keyring() -> Option<[u8; 32]> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).ok()?;
let b64 = entry.get_password().ok()?;
decode_key(b64.trim()).ok()
}
/// Load the key from the file fallback. Returns `None` if unavailable.
fn load_key_from_file() -> Option<[u8; 32]> {
let contents = fs::read_to_string(encryption_key_path()).ok()?;
decode_key(contents.trim()).ok()
}
/// Try to load an existing key.
///
/// Priority: process cache → file → keyring (last resort, may prompt).
/// The result is cached for the lifetime of the process.
pub fn load_existing_key() -> Option<[u8; 32]> {
load_key_from_file().or_else(load_key_from_keyring)
}
/// Persist the key. Writes to the file fallback always; writes to keyring
/// at most once per process to avoid repeated macOS Keychain prompts.
///
/// If the key is already cached and identical, this is a no-op.
pub fn save_key(key: &[u8; 32]) -> Result<()> {
let b64 = encode_key(key);
// Always write the file fallback.
let key_path = encryption_key_path();
fs_util::atomic_write(&key_path, &b64.as_bytes(), Some(0o600))?;
if let Err(_) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
.and_then(|entry| entry.set_password(&b64))
{
tracing::warn!("Keyring unavailable encryption key stored in file only");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Encrypt / Decrypt helpers for serializable data
// ---------------------------------------------------------------------------
/// Encrypt serializable data: serialize → AES-256-GCM encrypt.
pub fn encrypt_data<T: serde::Serialize + ?Sized>(data: &T, key: &[u8; 32]) -> Result<Vec<u8>> {
let json =
serde_json::to_vec(data).map_err(|e| anyhow::anyhow!("JSON serialize error: {e:#}"))?;
Ok(cipher::encrypt(key, &json)?)
}
/// Decrypt data: AES-256-GCM decrypt → deserialize.
pub fn decrypt_data<T: serde::de::DeserializeOwned>(data: &[u8], key: &[u8; 32]) -> Result<T> {
let decrypted = cipher::decrypt(key, data)?;
serde_json::from_slice(&decrypted).map_err(|e| anyhow::anyhow!("JSON deserialize error: {e:#}"))
}
/// Try to decrypt data using the cached/keyring key first; on failure, fall back to the file key.
pub fn try_decrypt_data<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T> {
// 1. Try cached key (covers both keyring and file sources)
if let Some(key) = load_key_from_file() {
if let Ok(result) = decrypt_data::<T>(data, &key) {
return Ok(result);
}
tracing::debug!("Cached key failed to decrypt, trying file key directly…");
}
// 2. Fall back to file key (in case cache holds a stale keyring key)
let key = load_key_from_file().ok_or(anyhow::anyhow!("解密数据失败(未找到有效密钥)",))?;
decrypt_data(data, &key)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
// -----------------------------------------------------------------------
// encode_key / decode_key
// -----------------------------------------------------------------------
#[test]
fn encode_decode_roundtrip() {
let key = generate_random_key();
let encoded = encode_key(&key);
let decoded = decode_key(&encoded).unwrap();
assert_eq!(key, decoded);
}
#[test]
fn decode_invalid_base64_fails() {
assert!(decode_key("not-valid-base64!!!").is_err());
}
#[test]
fn decode_wrong_length_fails() {
// Valid base64 but only 16 bytes, not 32
let short = base64::prelude::BASE64_STANDARD.encode([0u8; 16]);
assert!(decode_key(&short).is_err());
}
#[test]
fn decode_trims_whitespace() {
let key = generate_random_key();
let encoded = format!(" {} \n", encode_key(&key));
let decoded = decode_key(encoded.trim()).unwrap();
assert_eq!(key, decoded);
}
// -----------------------------------------------------------------------
// generate_random_key
// -----------------------------------------------------------------------
#[test]
fn random_keys_are_unique() {
let a = generate_random_key();
let b = generate_random_key();
assert_ne!(a, b);
}
#[test]
fn random_key_is_32_bytes() {
let key = generate_random_key();
assert_eq!(key.len(), 32);
}
// -----------------------------------------------------------------------
// encrypt_data / decrypt_data
// -----------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct TestPayload {
name: String,
value: u64,
}
#[test]
fn encrypt_decrypt_data_roundtrip() {
let key = generate_random_key();
let payload = TestPayload {
name: "test".into(),
value: 42,
};
let encrypted = encrypt_data(&payload, &key).unwrap();
let decrypted: TestPayload = decrypt_data(&encrypted, &key).unwrap();
assert_eq!(payload, decrypted);
}
#[test]
fn encrypt_decrypt_data_with_slice() {
let key = generate_random_key();
let items = vec![
TestPayload {
name: "a".into(),
value: 1,
},
TestPayload {
name: "b".into(),
value: 2,
},
];
let encrypted = encrypt_data(&items, &key).unwrap();
let decrypted: Vec<TestPayload> = decrypt_data(&encrypted, &key).unwrap();
assert_eq!(items, decrypted);
}
#[test]
fn decrypt_data_with_wrong_key_fails() {
let key1 = generate_random_key();
let key2 = generate_random_key();
let payload = TestPayload {
name: "secret".into(),
value: 99,
};
let encrypted = encrypt_data(&payload, &key1).unwrap();
assert!(decrypt_data::<TestPayload>(&encrypted, &key2).is_err());
}
#[test]
fn decrypt_data_with_corrupted_data_fails() {
let key = generate_random_key();
assert!(decrypt_data::<TestPayload>(b"garbage", &key).is_err());
}
#[test]
fn encrypt_decrypt_empty_vec() {
let key = generate_random_key();
let items: Vec<TestPayload> = vec![];
let encrypted = encrypt_data(&items, &key).unwrap();
let decrypted: Vec<TestPayload> = decrypt_data(&encrypted, &key).unwrap();
assert!(decrypted.is_empty());
}
}
+4
View File
@@ -0,0 +1,4 @@
mod cipher;
mod keystore;
pub use keystore::*;
+138
View File
@@ -0,0 +1,138 @@
use std::fs;
use std::io::Write;
use std::path::Path;
use anyhow::Result;
/// Atomically write `data` (bytes) to `path`, overwriting if the file already exists.
///
/// Uses `tempfile::NamedTempFile` to create a temporary file in the same directory,
/// then atomically renames it to the target path via `persist`.
pub fn atomic_write(path: &Path, data: &[u8], mode: Option<u32>) -> Result<()> {
let Some(parent) = path.parent() else {
anyhow::bail!("无效文件路径:{}", path.display());
};
fs::create_dir_all(parent)?;
// Create a temp file in the same directory (same filesystem → atomic rename).
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
// Set permissions before writing content (avoid permission window).
#[cfg(unix)]
if let Some(m) = mode {
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(fs::Permissions::from_mode(m))?;
}
tmp.write_all(data)?;
tmp.as_file().flush()?;
tmp.as_file().sync_all()?;
// Atomically rename to the target path.
tmp.persist(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().unwrap()
}
// -----------------------------------------------------------------------
// atomic_write
// -----------------------------------------------------------------------
#[test]
fn write_creates_file_with_correct_content() {
let dir = tmp();
let path = dir.path().join("test.bin");
let data = b"hello world";
atomic_write(&path, data, None).unwrap();
assert_eq!(fs::read(&path).unwrap(), data);
}
#[test]
fn write_creates_parent_directories() {
let dir = tmp();
let path = dir.path().join("a").join("b").join("deep.bin");
atomic_write(&path, b"nested", None).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"nested");
}
#[test]
fn overwrite_replaces_existing_file() {
let dir = tmp();
let path = dir.path().join("overwrite.bin");
atomic_write(&path, b"first", None).unwrap();
atomic_write(&path, b"second", None).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"second");
}
#[test]
fn no_temp_file_left_after_success() {
let dir = tmp();
let path = dir.path().join("clean.bin");
atomic_write(&path, b"ok", None).unwrap();
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
// Only the target file should remain; no `.tmp.*` leftovers.
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name(), "clean.bin");
}
#[test]
fn write_empty_data() {
let dir = tmp();
let path = dir.path().join("empty.bin");
atomic_write(&path, b"", None).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"");
}
// -----------------------------------------------------------------------
// Unix-specific: file permissions
// -----------------------------------------------------------------------
#[cfg(unix)]
mod unix {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn write_sets_file_permissions() {
let dir = tmp();
let path = dir.path().join("secret.bin");
atomic_write(&path, b"secret", Some(0o600)).unwrap();
let perms = fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
fn write_sets_readonly_permissions() {
let dir = tmp();
let path = dir.path().join("readonly.bin");
atomic_write(&path, b"ro", Some(0o400)).unwrap();
let perms = fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o400);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
use crate::{config, json_rpc};
use anyhow::Result;
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static TOOLS_CACHE: RefCell<HashMap<String, Vec<serde_json::Value>>> = RefCell::new(HashMap::new());
}
/// Fetch and display the available tools for a given category via JSON-RPC.
pub async fn show_category_tools(category: &str) -> Result<()> {
// Try to dynamically fetch the tool list via JSON-RPC
let res = json_rpc::send(category, "tools/list", None, None).await?;
// Parse the returned tool list
let Some(tools) = res
.get("result")
.and_then(|r| r.get("tools"))
.and_then(|r| r.as_array())
else {
anyhow::bail!("无法获取 {} 品类的工具列表: {res}", category);
};
// Store to cache
TOOLS_CACHE.with(|cache| {
let mut cache_ref = cache.borrow_mut();
cache_ref.insert(category.to_string(), tools.clone());
});
// Get category description
let category_description = config::get_categories()
.iter()
.find(|info| info.name == category)
.map(|info| info.description)
.unwrap_or("");
let wecom = env!("CARGO_BIN_NAME");
println!("# {} {}", category, category_description);
println!("");
println!("使用方式:");
println!(" {} {} <method> [json_args]", wecom, category);
println!("");
println!("选项:");
println!(" -h, --help 显示详细的工具 schema 信息");
println!("");
for tool in tools {
let Some(name) = tool.get("name").and_then(|n| n.as_str()) else {
continue;
};
println!("");
println!("## {}", name);
if let Some(description) = tool.get("description").and_then(|d| d.as_str()) {
println!("");
println!("{}", description);
}
}
Ok(())
}
pub async fn show_tool_help(category: &str, tool_name: &str) -> Result<()> {
// First try to get tool information from cache
let tools = TOOLS_CACHE.with(|cache| {
let cache_ref = cache.borrow();
cache_ref.get(category).cloned()
});
let tools = if let Some(tools) = tools {
tools
} else {
// If not in cache, fetch dynamically
let res = json_rpc::send(category, "tools/list", None, None).await?;
let tools = res
.get("result")
.and_then(|r| r.get("tools"))
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
// Store to cache
TOOLS_CACHE.with(|cache| {
let mut cache_ref = cache.borrow_mut();
cache_ref.insert(category.to_string(), tools.clone());
});
tools
};
// Find specific tool
let tool = tools.iter().find(|t| {
t.get("name")
.and_then(|n| n.as_str())
.map(|name| name == tool_name)
.unwrap_or(false)
});
if let Some(tool) = tool {
println!("# {} - {}", category, tool_name);
println!();
if let Some(description) = tool.get("description").and_then(|d| d.as_str()) {
println!("## 描述");
println!("{}", description);
println!();
}
if let Some(input_schema) = tool.get("inputSchema") {
println!("## 输入参数");
println!("```json");
println!(
"{}",
serde_json::to_string_pretty(input_schema).unwrap_or_default()
);
println!("```");
println!();
}
if let Some(parameters) = tool.get("parameters") {
println!("## 参数");
println!("```json");
println!(
"{}",
serde_json::to_string_pretty(parameters).unwrap_or_default()
);
println!("```");
}
} else {
println!("未找到工具: {} 在品类 {}", tool_name, category);
}
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
use anyhow::Result;
use serde::Serialize;
use serde_json::Value;
use crate::mcp;
#[derive(Debug, Clone, Serialize)]
struct JsonRpcRequest {
jsonrpc: &'static str,
id: String,
method: String,
params: Option<Value>,
}
/// Send a JSON-RPC 2.0 request to the MCP endpoint for the given category and method.
pub async fn send(
category: &str,
method: &str,
params: Option<Value>,
timeout_ms: Option<i32>,
) -> Result<Value> {
let mcp_url = mcp::get_mcp_url(category).await?;
let body = JsonRpcRequest {
jsonrpc: "2.0",
id: mcp::gen_req_id("mcp_rpc"),
method: method.to_string(),
params,
};
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30000) as u64);
let request = reqwest::Client::builder()
.build()?
.post(&mcp_url)
.timeout(timeout)
.header("Accept", "application/json")
.json(&body);
let response = request.send().await.map_err(|err| {
if err.is_timeout() {
anyhow::anyhow!("MCP请求超时 ({}ms)", timeout.as_millis())
} else {
anyhow::anyhow!("MCP网络请求失败: {err}")
}
})?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("MCP请求失败 (HTTP {status})");
}
let body_text = response.text().await?;
let rpc_res = serde_json::from_str::<Value>(&body_text)?;
Ok(rpc_res)
}
+61
View File
@@ -0,0 +1,61 @@
use tracing_subscriber::prelude::*;
use crate::constants::env;
/// Initialize the tracing subscriber with optional stderr and file logging layers.
pub fn init_logging() {
let stderr_filter = std::env::var(env::LOG_LEVEL).ok();
let log_file_dir = std::env::var(env::LOG_FILE).ok();
if stderr_filter.is_none() && log_file_dir.is_none() {
return;
}
let registry = tracing_subscriber::registry();
// Stderr layer: human-readable
let stderr_layer = stderr_filter.map(|filter| {
let env_filter = tracing_subscriber::EnvFilter::new(filter);
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(false)
.compact()
.with_filter(env_filter)
});
// File layer: JSON-line output with daily rotation
let (file_layer, guard) = if let Some(ref dir) = log_file_dir {
let file_appender = tracing_appender::rolling::daily(dir, "ww.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.with_target(true)
.with_filter(tracing_subscriber::EnvFilter::new("ww=debug"));
(Some(layer), Some(guard))
} else {
(None, None)
};
// Compose layers and set as global subscriber.
// The guard is leaked intentionally so the non-blocking writer stays
// alive for the lifetime of the process.
let subscriber = registry.with(stderr_layer).with(file_layer);
if tracing::subscriber::set_global_default(subscriber).is_ok() {
// Leak the guard so the non-blocking writer lives for the process lifetime.
// This is the recommended pattern from tracing-appender docs.
std::mem::forget(guard);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_logging_default_no_panic() {
unsafe { std::env::remove_var(env::LOG_LEVEL) };
unsafe { std::env::remove_var(env::LOG_FILE) };
init_logging();
}
}
+50
View File
@@ -0,0 +1,50 @@
mod auth;
mod cmd;
mod config;
mod constants;
mod crypto;
mod fs_util;
mod help;
mod json_rpc;
mod logging;
mod mcp;
mod media;
use anyhow::Result;
use clap::{Args, Command};
/// Entry point: parse CLI arguments and dispatch to the corresponding subcommand handler.
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
logging::init_logging();
let categories = config::get_categories();
let mut cmd = Command::new(env!("CARGO_BIN_NAME"))
.version(env!("CARGO_PKG_VERSION"))
.subcommand_required(true)
.arg_required_else_help(true)
.disable_help_subcommand(true)
.subcommand(
cmd::init::InitArgs::augment_args(Command::new("init")).about("Documentation for init"),
);
for category in categories.iter() {
cmd = cmd.subcommand(cmd::call::CallArgs::augment_args(
Command::new(category.name)
.about(category.description)
.disable_help_subcommand(true)
.disable_help_flag(true),
));
}
let matches = cmd.get_matches();
match matches.subcommand() {
Some(("init", matches)) => cmd::init::handle_init_cmd(matches).await,
Some((category, matches)) => cmd::call::handle_call_cmd(category, matches).await,
_ => anyhow::bail!("未知命令"),
}
}
+356
View File
@@ -0,0 +1,356 @@
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::constants;
use crate::crypto;
use crate::{auth, fs_util};
// ---------------------------------------------------------------------------
// Request
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct GetMcpConfigRequest {
pub bot_id: String,
pub time: u64,
pub nonce: String,
pub signature: String,
}
impl GetMcpConfigRequest {
/// Build a signed request from stored bot credentials
pub fn build() -> Result<Self> {
let bot = auth::get_bot_info().ok_or_else(|| {
anyhow::anyhow!(
"未找到企业微信机器人信息,请先运行 `{} init`",
env!("CARGO_BIN_NAME")
)
})?;
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let nonce = super::gen_req_id("mcp");
let signature = sign(&bot.secret, &bot.id, time, &nonce);
Ok(Self {
bot_id: bot.id,
time,
nonce,
signature,
})
}
}
// ---------------------------------------------------------------------------
// Response
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
pub struct GetMcpConfigResponse {
pub errcode: i32,
pub errmsg: String,
#[serde(default)]
pub list: Vec<McpConfigItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfigItem {
pub url: String,
#[serde(rename = "type")]
pub transport_type: String,
pub is_authed: bool,
pub biz_type: String,
}
// ---------------------------------------------------------------------------
// Signature
// ---------------------------------------------------------------------------
/// Compute the request signature.
///
/// Algorithm: `sha256_hex(secret + bot_id + time + nonce)`
/// where `sha256_hex` uses the standard zero-padded lowercase hex format (`%02x`).
pub fn sign(secret: &str, bot_id: &str, time: u64, nonce: &str) -> String {
let input = format!("{secret}{bot_id}{time}{nonce}");
sha256_hex(&input)
}
/// Compute the SHA-256 hash of `input` and return it as a lowercase hex string.
fn sha256_hex(input: &str) -> String {
let hash = Sha256::digest(input.as_bytes());
let mut result = String::with_capacity(64);
for byte in hash.iter() {
result.push_str(&format!("{:02x}", byte));
}
result
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
/// Return the file path for the encrypted MCP config cache.
fn mcp_config_path() -> std::path::PathBuf {
crate::constants::config_dir().join("mcp_config.enc")
}
/// Read cached MCP config list from the encrypted file.
pub fn load_mcp_config() -> Option<Vec<McpConfigItem>> {
let data = fs::read(mcp_config_path()).ok()?;
crypto::try_decrypt_data(&data).ok()
}
/// Encrypt and persist the MCP config list to disk.
pub fn save_mcp_config(items: &[McpConfigItem]) -> Result<()> {
let key = crypto::load_existing_key().unwrap_or_else(|| {
let k = crypto::generate_random_key();
tracing::info!("Generated new encryption key for MCP config");
k
});
crypto::save_key(&key)?;
let encrypted = crypto::encrypt_data(items, &key)?;
let path = mcp_config_path();
fs_util::atomic_write(&path, &encrypted, Some(0o600))?;
tracing::info!("MCP config saved to {}", path.display());
Ok(())
}
/// Remove the cached MCP config file from disk.
pub fn clear_mcp_config() {
let path = mcp_config_path();
if path.exists() {
let _ = fs::remove_file(&path);
tracing::info!("MCP config cache removed: {}", path.display());
}
}
#[cfg(test)]
/// Encrypt and write the config list to a specific path with a given key (test helper).
fn save_mcp_config_to_path(
items: &[McpConfigItem],
path: &std::path::Path,
key: &[u8; 32],
) -> Result<()> {
let encrypted = crypto::encrypt_data(items, key)?;
fs_util::atomic_write(path, &encrypted, Some(0o600))
}
#[cfg(test)]
/// Read and decrypt the config list from a specific path with a given key (test helper).
fn load_mcp_config_from_path(path: &std::path::Path, key: &[u8; 32]) -> Option<Vec<McpConfigItem>> {
let data = fs::read(path).ok()?;
crypto::decrypt_data(&data, key).ok()
}
// ---------------------------------------------------------------------------
// API Call
// ---------------------------------------------------------------------------
/// Return the MCP config list, reading from local cache first; fall back to a network fetch.
///
/// On a cache miss the remote result is automatically persisted to disk.
pub async fn get_mcp_config() -> Result<GetMcpConfigResponse> {
if let Some(list) = load_mcp_config() {
tracing::debug!("Loaded MCP config from local cache");
return Ok(GetMcpConfigResponse {
errcode: 0,
errmsg: "ok (cached)".into(),
list,
});
}
fetch_mcp_config().await
}
/// Always fetch the MCP config from the server, bypassing local cache, and persist the result.
pub async fn fetch_mcp_config() -> Result<GetMcpConfigResponse> {
let resp = fetch_mcp_config_from_server().await?;
save_mcp_config(&resp.list)?;
Ok(resp)
}
/// Send the actual HTTP request to the MCP config endpoint.
async fn fetch_mcp_config_from_server() -> Result<GetMcpConfigResponse> {
let request = GetMcpConfigRequest::build()?;
let response = reqwest::Client::builder()
.build()?
.post(&constants::mcp_config_endpoint())
.json(&request)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let mut body = response
.text()
.await
.unwrap_or_else(|_| "<Failed to read response body>".to_string());
if body.is_empty() {
body = "<Empty response body>".to_string();
}
anyhow::bail!("get_mcp_config failed with status {status}: {body}");
}
let resp = response.json::<GetMcpConfigResponse>().await?;
if resp.errcode != 0 {
anyhow::bail!("获取 MCP 配置失败:[{}] {}", resp.errcode, resp.errmsg);
}
Ok(resp)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto;
fn sample_items() -> Vec<McpConfigItem> {
vec![
McpConfigItem {
url: "https://example.com/mcp/contact".into(),
transport_type: "streamable-http".into(),
is_authed: true,
biz_type: "contact".into(),
},
McpConfigItem {
url: "https://example.com/mcp/msg".into(),
transport_type: "streamable-http".into(),
is_authed: false,
biz_type: "msg".into(),
},
]
}
// -----------------------------------------------------------------------
// Signature tests
// -----------------------------------------------------------------------
#[test]
fn sha256_hex_matches_cpp_format() {
let result = sha256_hex("test");
// {:02x} format: standard lowercase hex, two digits per byte, zero-padded
assert_eq!(
result,
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
}
#[test]
fn sign_produces_non_empty_signature() {
let sig = sign("my_secret", "bot_123", 1774772074, "abc123");
assert!(!sig.is_empty());
}
#[test]
fn sign_is_deterministic() {
let a = sign("sec", "id", 100, "nonce");
let b = sign("sec", "id", 100, "nonce");
assert_eq!(a, b);
}
#[test]
fn sign_changes_with_different_inputs() {
let a = sign("sec", "id", 100, "nonce1");
let b = sign("sec", "id", 100, "nonce2");
assert_ne!(a, b);
}
// -----------------------------------------------------------------------
// Persistence tests
// -----------------------------------------------------------------------
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.enc");
let key = crypto::generate_random_key();
let items = sample_items();
save_mcp_config_to_path(&items, &path, &key).unwrap();
let loaded = load_mcp_config_from_path(&path, &key).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].biz_type, "contact");
assert_eq!(loaded[0].url, "https://example.com/mcp/contact");
assert_eq!(loaded[1].biz_type, "msg");
assert!(!loaded[1].is_authed);
}
#[test]
fn load_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.enc");
let key = crypto::generate_random_key();
assert!(load_mcp_config_from_path(&path, &key).is_none());
}
#[test]
fn load_returns_none_with_wrong_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.enc");
let key = crypto::generate_random_key();
let wrong_key = crypto::generate_random_key();
let items = sample_items();
save_mcp_config_to_path(&items, &path, &key).unwrap();
assert!(load_mcp_config_from_path(&path, &wrong_key).is_none());
}
#[test]
fn load_returns_none_with_corrupted_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.enc");
let key = crypto::generate_random_key();
std::fs::write(&path, b"garbage data").unwrap();
assert!(load_mcp_config_from_path(&path, &key).is_none());
}
#[test]
fn save_overwrites_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.enc");
let key = crypto::generate_random_key();
let items_v1 = vec![McpConfigItem {
url: "https://v1.example.com".into(),
transport_type: "streamable-http".into(),
is_authed: true,
biz_type: "v1".into(),
}];
save_mcp_config_to_path(&items_v1, &path, &key).unwrap();
let items_v2 = sample_items();
save_mcp_config_to_path(&items_v2, &path, &key).unwrap();
let loaded = load_mcp_config_from_path(&path, &key).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].biz_type, "contact");
}
#[test]
fn save_empty_list() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.enc");
let key = crypto::generate_random_key();
save_mcp_config_to_path(&[], &path, &key).unwrap();
let loaded = load_mcp_config_from_path(&path, &key).unwrap();
assert!(loaded.is_empty());
}
}
+35
View File
@@ -0,0 +1,35 @@
pub(crate) mod config;
use anyhow::Result;
use rand::Rng;
/// Look up the MCP URL for the given `category` (matched against `biz_type`).
pub async fn get_mcp_url(category: &str) -> Result<String> {
let resp = config::get_mcp_config().await?;
let target = resp
.list
.iter()
.find(|item| item.biz_type == category)
.ok_or_else(|| anyhow::anyhow!("当前企业暂不支持 {category} 命令"))?;
Ok(target.url.clone())
}
/// Generate a request ID in the format: `{prefix}_{timestamp_ms}_{random_hex}`.
pub fn gen_req_id(prefix: &str) -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let random = generate_random_hex(8);
format!("{prefix}_{timestamp}_{random}")
}
/// Generate a random hex string of the specified character length.
fn generate_random_hex(length: usize) -> String {
let byte_len = (length + 1) / 2;
let bytes: Vec<u8> = (0..byte_len).map(|_| rand::rng().random::<u8>()).collect();
let hex = hex::encode(bytes);
hex[..length].to_string()
}
+98
View File
@@ -0,0 +1,98 @@
mod utils;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
const INBOUND_MAX_BYTES: usize = 20 * 1024 * 1024;
/// Intercept a `get_msg_media` response: decode base64 payload, save to disk, and replace the response with a local file reference.
pub async fn intercept_media_response(res: Value) -> Result<Value> {
let Some(result) = res.get("result") else {
return Ok(res);
};
// 1. Extract the content array from the MCP result
let Some(content) = result.get("content").and_then(|c| c.as_array()) else {
return Ok(res);
};
// Find the entry where type="text" and text is a string
let text_item = content.iter().find(|c| {
c.get("type").and_then(|t| t.as_str()) == Some("text")
&& c.get("text").and_then(|t| t.as_str()).is_some()
});
let Some(text_item) = text_item else {
return Ok(res);
};
let text = text_item["text"].as_str().unwrap();
// 2. Parse the business JSON
let biz_data: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return Ok(res), // Not JSON format, return as-is
};
// 3. Validate business response: return as-is when errcode !== 0 or no media_item
if biz_data.get("errcode").and_then(|c| c.as_i64()) != Some(0) {
return Ok(res);
}
let Some(media_item) = biz_data.get("media_item") else {
return Ok(res);
};
let Some(base64_data) = media_item.get("base64_data").and_then(|d| d.as_str()) else {
return Ok(res);
};
let media_name = media_item.get("name").and_then(|n| n.as_str());
let media_type = media_item.get("type").and_then(|t| t.as_str());
let media_id = media_item.get("media_id").and_then(|i| i.as_str());
// 4. Decode base64 → buffer
use base64::Engine as _;
let buffer = base64::engine::general_purpose::STANDARD
.decode(base64_data)
.context("base64解码失败")?;
// Validate size
if buffer.len() > INBOUND_MAX_BYTES {
bail!(
"媒体文件过大: {} 字节 (最大 {} 字节)",
buffer.len(),
INBOUND_MAX_BYTES
);
}
// 5. Detect MIME type
let content_type = utils::detect_mime(media_name, &buffer);
// 6. Save to local file
let file_path = utils::save_media(media_name, media_id, &content_type, &buffer).await?;
// 7. Build a concise response: remove base64_data, add local path
let new_biz_data = json!({
"errcode": 0,
"errmsg": "ok",
"media_item": {
"media_id": media_id,
"name": media_name.unwrap_or_else(|| file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")),
"type": media_type,
"local_path": file_path.to_string_lossy(),
"size": buffer.len(),
"content_type": content_type,
},
});
// 8. Replace res in-place with the modified MCP result structure
Ok(json!({
"result": {
"content": [{
"type": "text",
"text": serde_json::to_string(&new_biz_data)?,
}],
},
}))
}
+140
View File
@@ -0,0 +1,140 @@
use std::{
io::Write,
path::{Path, PathBuf},
};
use crate::constants::media_dir;
/// Maximum dedup index suffix; give up retrying once exceeded.
const MAX_DEDUP_INDEX: u32 = 100;
/// Detect the MIME type by file extension first, then by magic bytes, falling back to `application/octet-stream`.
pub fn detect_mime(file_name: Option<&str>, buffer: &[u8]) -> String {
// Try to infer from the file extension first
if let Some(name) = file_name {
let ext = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let mime = mime_guess::from_ext(ext).first_or_octet_stream();
if mime != mime::APPLICATION_OCTET_STREAM {
return mime.to_string();
}
}
// Then try to infer from magic bytes
if let Some(kind) = infer::get(buffer) {
return kind.mime_type().to_string();
}
"application/octet-stream".to_string()
}
/// Atomically save media data to the media directory, deduplicating file names when collisions occur.
pub async fn save_media(
media_name: Option<&str>,
media_id: Option<&str>,
content_type: &str,
data: &[u8],
) -> anyhow::Result<PathBuf> {
let dir = media_dir();
tokio::fs::create_dir_all(&dir).await?;
let (stem, ext) = determine_file_name(media_name, media_id, content_type);
let mut tmp = tempfile::NamedTempFile::new_in(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
tmp.write_all(data)?;
tmp.as_file().sync_all()?;
// First try the original file name without an index
let target = dir.join(make_file_name(&stem, None, &ext));
tmp = match tmp.persist_noclobber(&target) {
Ok(_) => return Ok(target),
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => e.file,
Err(e) => return Err(e.error.into()),
};
// File already exists; try stem.{index}.ext, index from 0 to MAX_DEDUP_INDEX
for idx in 0..MAX_DEDUP_INDEX {
let target = dir.join(make_file_name(&stem, Some(idx), &ext));
tmp = match tmp.persist_noclobber(&target) {
Ok(_) => return Ok(target),
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => e.file,
Err(e) => return Err(e.error.into()),
};
}
anyhow::bail!(
"媒体文件保存失败,目标文件已存在:{}",
target.to_string_lossy()
);
}
/// Build a file name in the format `stem[.index].ext`.
fn make_file_name(stem: &str, index: Option<u32>, ext: &str) -> String {
match (index, ext.is_empty()) {
(None, true) => stem.to_string(),
(None, false) => format!("{stem}.{ext}"),
(Some(i), true) => format!("{stem}.{i}"),
(Some(i), false) => format!("{stem}.{i}.{ext}"),
}
}
/// Derive the (stem, extension) pair from media name, media ID, or content type.
pub fn determine_file_name(
media_name: Option<&str>,
media_id: Option<&str>,
content_type: &str,
) -> (String, String) {
if let Some(name) = media_name.and_then(sanitize_file_name) {
let path = Path::new(&name);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&name)
.to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
return (stem, ext);
}
let stem = media_id
.and_then(sanitize_file_name)
.unwrap_or_else(|| "media".to_string());
let ext = mime_guess::get_mime_extensions_str(content_type)
.and_then(|exts| exts.first())
.copied()
.unwrap_or("bin")
.to_string();
(stem, ext)
}
/// Sanitize a file name by removing illegal characters; returns `None` if the result is empty.
fn sanitize_file_name(name: &str) -> Option<String> {
let options = sanitize_filename::Options {
truncate: true,
windows: true,
replacement: "_",
};
let sanitized = sanitize_filename::sanitize_with_options(name, options);
// Return None if the sanitized result is empty (all illegal characters), so the caller can fallback
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}