weixin-mcp 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +107 -63
- package/README.md +107 -67
- package/dist/api.d.ts +20 -0
- package/dist/api.js +103 -0
- package/dist/cdn.d.ts +24 -0
- package/dist/cdn.js +130 -0
- package/dist/index.js +60 -1
- package/package.json +1 -1
- package/src/api.ts +153 -0
- package/src/cdn.ts +185 -0
- package/src/index.ts +71 -0
package/README.en.md
CHANGED
|
@@ -1,54 +1,51 @@
|
|
|
1
1
|
# weixin-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/weixin-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
🤖 **WeChat MCP Server** — Let AI assistants send and receive WeChat messages
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Expose WeChat capabilities as [MCP](https://modelcontextprotocol.io/) tools. Claude Desktop, Cursor, OpenClaw, and other AI assistants can directly:
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
- 📨 **Send messages** — text, images, files, videos
|
|
11
|
+
- 📬 **Receive messages** — polling or real-time Webhook push
|
|
12
|
+
- 👥 **Manage contacts** — auto-track conversation users
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
npx weixin-mcp status
|
|
14
|
+
[中文](./README.md) | [ClawHub Skill](https://clawhub.com/skills/weixin-mcp)
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
npx weixin-mcp
|
|
18
|
-
```
|
|
16
|
+
---
|
|
19
17
|
|
|
20
|
-
##
|
|
18
|
+
## ✨ Features
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
| `npx weixin-mcp start [--port n]` | Start HTTP daemon (background, default 3001) |
|
|
28
|
-
| `npx weixin-mcp stop` | Stop daemon |
|
|
29
|
-
| `npx weixin-mcp restart` | Restart daemon |
|
|
30
|
-
| `npx weixin-mcp logs [-f]` | View daemon logs (-f for follow) |
|
|
31
|
-
| `npx weixin-mcp send <userId> <text>` | Send message (supports short ID prefix) |
|
|
32
|
-
| `npx weixin-mcp poll [--watch] [--reset]` | Poll messages (--watch for continuous) |
|
|
33
|
-
| `npx weixin-mcp contacts` | List contacts (users who messaged the bot) |
|
|
34
|
-
| `npx weixin-mcp accounts [list]` | List all accounts |
|
|
35
|
-
| `npx weixin-mcp accounts remove <id>` | Remove an account |
|
|
36
|
-
| `npx weixin-mcp accounts clean` | Remove duplicates (keep newest per userId) |
|
|
37
|
-
| `npx weixin-mcp update` | Check and install latest version |
|
|
38
|
-
| `npx weixin-mcp --version` | Print version |
|
|
20
|
+
- **Zero config** — scan QR to login, no official account needed
|
|
21
|
+
- **All message types** — text / image / file / video
|
|
22
|
+
- **Real-time push** — Webhook mode for instant delivery
|
|
23
|
+
- **Multi-account** — run multiple instances in different directories
|
|
24
|
+
- **MCP standard** — compatible with all MCP clients
|
|
39
25
|
|
|
40
|
-
|
|
26
|
+
---
|
|
41
27
|
|
|
42
|
-
|
|
28
|
+
## 🚀 Quick Start
|
|
43
29
|
|
|
44
30
|
```bash
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
# 1. QR code login
|
|
32
|
+
npx weixin-mcp login
|
|
33
|
+
|
|
34
|
+
# 2. Check status
|
|
35
|
+
npx weixin-mcp status
|
|
36
|
+
|
|
37
|
+
# 3. Send a test message
|
|
38
|
+
npx weixin-mcp send <userId> "Hello from CLI!"
|
|
39
|
+
|
|
40
|
+
# 4. Start MCP server
|
|
41
|
+
npx weixin-mcp
|
|
47
42
|
```
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 🔌 Claude Desktop Integration
|
|
50
47
|
|
|
51
|
-
|
|
48
|
+
Edit `claude_desktop_config.json`:
|
|
52
49
|
|
|
53
50
|
```json
|
|
54
51
|
{
|
|
@@ -61,47 +58,94 @@ Add to `claude_desktop_config.json`:
|
|
|
61
58
|
}
|
|
62
59
|
```
|
|
63
60
|
|
|
64
|
-
|
|
61
|
+
Restart Claude Desktop. Now Claude can send and receive WeChat messages for you!
|
|
62
|
+
|
|
63
|
+
---
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
## 🛠️ CLI Commands
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `login` | QR code login |
|
|
70
|
+
| `status` | Show account and daemon status |
|
|
71
|
+
| `send <to> <text>` | Send message (supports short ID) |
|
|
72
|
+
| `poll [--watch]` | Poll messages |
|
|
73
|
+
| `contacts` | List contacts |
|
|
74
|
+
| `start [--webhook url]` | Start HTTP daemon |
|
|
75
|
+
| `stop` / `restart` | Stop/restart daemon |
|
|
76
|
+
| `logs [-f]` | View logs |
|
|
77
|
+
| `accounts list\|clean\|remove` | Manage accounts |
|
|
78
|
+
| `update` | Update to latest version |
|
|
79
|
+
|
|
80
|
+
### Short ID Matching
|
|
81
|
+
|
|
82
|
+
Use a prefix instead of full ID when unique:
|
|
67
83
|
|
|
68
84
|
```bash
|
|
69
|
-
npx weixin-mcp
|
|
85
|
+
npx weixin-mcp send abc12 "hello"
|
|
86
|
+
# ✓ Resolved "abc12" → abc123xyz456@im.wechat
|
|
70
87
|
```
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
- Health check: `http://localhost:3001/health`
|
|
89
|
+
---
|
|
74
90
|
|
|
75
|
-
## MCP Tools
|
|
91
|
+
## 🔧 MCP Tools
|
|
76
92
|
|
|
77
93
|
| Tool | Description | Parameters |
|
|
78
94
|
|------|-------------|------------|
|
|
79
|
-
| `weixin_send` | Send text
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
95
|
+
| `weixin_send` | Send text | `to`, `text`, `context_token?` |
|
|
96
|
+
| `weixin_send_image` | Send image | `to`, `source`, `caption?` |
|
|
97
|
+
| `weixin_send_file` | Send file | `to`, `source`, `caption?` |
|
|
98
|
+
| `weixin_poll` | Poll messages | `reset_cursor?` |
|
|
99
|
+
| `weixin_contacts` | List contacts | - |
|
|
100
|
+
| `weixin_get_config` | Get config | `user_id` |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 📡 Webhook Mode
|
|
105
|
+
|
|
106
|
+
Receive messages in real-time:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx weixin-mcp start --webhook http://your-server/weixin-hook
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Messages are POSTed to your webhook:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"event": "weixin_messages",
|
|
117
|
+
"messages": [{
|
|
118
|
+
"from_user_id": "...",
|
|
119
|
+
"item_list": [{"type": 1, "text_item": {"text": "Hello"}}],
|
|
120
|
+
"context_token": "..."
|
|
121
|
+
}],
|
|
122
|
+
"timestamp": "2026-03-22T19:00:00.000Z"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 🏠 Data Storage
|
|
129
|
+
|
|
130
|
+
Priority: `$WEIXIN_MCP_DIR` > `~/.openclaw/openclaw-weixin/` > `~/.weixin-mcp/`
|
|
83
131
|
|
|
84
|
-
|
|
132
|
+
| File | Description |
|
|
133
|
+
|------|-------------|
|
|
134
|
+
| `accounts/*.json` | Login credentials |
|
|
135
|
+
| `contacts.json` | Contact book |
|
|
136
|
+
| `daemon.json` | Daemon state |
|
|
137
|
+
| `daemon.log` | Logs |
|
|
85
138
|
|
|
86
|
-
|
|
87
|
-
1. `WEIXIN_MCP_DIR` environment variable
|
|
88
|
-
2. `~/.openclaw/openclaw-weixin/` (if OpenClaw installed)
|
|
89
|
-
3. `~/.weixin-mcp/` (default)
|
|
139
|
+
---
|
|
90
140
|
|
|
91
|
-
|
|
92
|
-
- `accounts/<accountId>.json` — account token
|
|
93
|
-
- `accounts/<accountId>.cursor.json` — message cursor
|
|
94
|
-
- `contacts.json` — contact book
|
|
95
|
-
- `daemon.json` — daemon PID (HTTP mode only)
|
|
96
|
-
- `daemon.log` — daemon logs
|
|
141
|
+
## 🔗 Related Projects
|
|
97
142
|
|
|
98
|
-
|
|
143
|
+
- [OpenClaw](https://github.com/anthropics/openclaw) — AI Agent Infrastructure
|
|
144
|
+
- [MCP Protocol](https://modelcontextprotocol.io/) — Model Context Protocol
|
|
145
|
+
- [ClawHub](https://clawhub.com/) — Agent Skills Marketplace
|
|
99
146
|
|
|
100
|
-
|
|
101
|
-
|----------|-------------|
|
|
102
|
-
| `WEIXIN_MCP_DIR` | Custom data directory |
|
|
103
|
-
| `WEIXIN_ACCOUNT_ID` | Specify which account to use |
|
|
147
|
+
---
|
|
104
148
|
|
|
105
|
-
## License
|
|
149
|
+
## 📄 License
|
|
106
150
|
|
|
107
|
-
MIT
|
|
151
|
+
MIT © [bkmashiro](https://github.com/bkmashiro)
|
package/README.md
CHANGED
|
@@ -1,56 +1,51 @@
|
|
|
1
1
|
# weixin-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/weixin-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
🤖 **微信 MCP Server** — 让 AI 助手直接收发微信消息
|
|
6
7
|
|
|
7
|
-
[
|
|
8
|
+
将微信能力暴露为 [MCP](https://modelcontextprotocol.io/) 工具,Claude Desktop、Cursor、OpenClaw 等 AI 助手可以直接:
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
- 📨 **发送消息** — 文本、图片、文件、视频
|
|
11
|
+
- 📬 **接收消息** — 轮询或 Webhook 实时推送
|
|
12
|
+
- 👥 **管理联系人** — 自动记录对话用户
|
|
13
|
+
|
|
14
|
+
[English](./README.en.md) | [ClawHub Skill](https://clawhub.com/skills/weixin-mcp)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## ✨ 特性
|
|
19
|
+
|
|
20
|
+
- **零配置** — 扫码登录即用,无需申请公众号/企业微信
|
|
21
|
+
- **全类型消息** — 文本 / 图片 / 文件 / 视频
|
|
22
|
+
- **实时推送** — Webhook 模式,消息秒级推送到你的服务
|
|
23
|
+
- **多账号支持** — 不同目录运行多个实例
|
|
24
|
+
- **MCP 标准** — 兼容所有 MCP 客户端
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 🚀 快速开始
|
|
10
29
|
|
|
11
30
|
```bash
|
|
12
|
-
# 1.
|
|
31
|
+
# 1. 扫码登录
|
|
13
32
|
npx weixin-mcp login
|
|
14
33
|
|
|
15
34
|
# 2. 查看状态
|
|
16
35
|
npx weixin-mcp status
|
|
17
36
|
|
|
18
|
-
# 3.
|
|
37
|
+
# 3. 发消息测试
|
|
38
|
+
npx weixin-mcp send <userId> "Hello from CLI!"
|
|
39
|
+
|
|
40
|
+
# 4. 启动 MCP server
|
|
19
41
|
npx weixin-mcp
|
|
20
42
|
```
|
|
21
43
|
|
|
22
|
-
|
|
44
|
+
---
|
|
23
45
|
|
|
24
|
-
|
|
25
|
-
|------|------|
|
|
26
|
-
| `npx weixin-mcp login` | 扫码登录微信 |
|
|
27
|
-
| `npx weixin-mcp status` | 查看账号和 daemon 状态 |
|
|
28
|
-
| `npx weixin-mcp` | 启动 stdio MCP server(Claude Desktop) |
|
|
29
|
-
| `npx weixin-mcp start [--port n]` | 启动 HTTP daemon(后台,默认端口 3001) |
|
|
30
|
-
| `npx weixin-mcp stop` | 停止 daemon |
|
|
31
|
-
| `npx weixin-mcp restart` | 重启 daemon |
|
|
32
|
-
| `npx weixin-mcp logs [-f]` | 查看 daemon 日志(-f 实时跟踪) |
|
|
33
|
-
| `npx weixin-mcp send <userId> <text>` | 发送消息(支持短 ID 前缀匹配) |
|
|
34
|
-
| `npx weixin-mcp poll [--watch] [--reset]` | 拉取消息(--watch 持续监听) |
|
|
35
|
-
| `npx weixin-mcp contacts` | 查看联系人(给 bot 发过消息的用户) |
|
|
36
|
-
| `npx weixin-mcp accounts [list]` | 列出所有账号 |
|
|
37
|
-
| `npx weixin-mcp accounts remove <id>` | 删除账号 |
|
|
38
|
-
| `npx weixin-mcp accounts clean` | 清理重复账号(同 userId 保留最新) |
|
|
39
|
-
| `npx weixin-mcp update` | 检查并更新到最新版 |
|
|
40
|
-
| `npx weixin-mcp --version` | 查看版本 |
|
|
46
|
+
## 🔌 Claude Desktop 集成
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
发送消息时可以用用户 ID 的前缀,只要在联系人中唯一匹配即可:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
npx weixin-mcp send o9cq8 "hello"
|
|
48
|
-
# Resolved "o9cq8" → o9cq80x8ou646cs3Tt5EQgfsZRtI@im.wechat
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Claude Desktop 集成
|
|
52
|
-
|
|
53
|
-
在 `claude_desktop_config.json` 中添加:
|
|
48
|
+
编辑 `claude_desktop_config.json`:
|
|
54
49
|
|
|
55
50
|
```json
|
|
56
51
|
{
|
|
@@ -63,49 +58,94 @@ npx weixin-mcp send o9cq8 "hello"
|
|
|
63
58
|
}
|
|
64
59
|
```
|
|
65
60
|
|
|
66
|
-
重启 Claude Desktop
|
|
61
|
+
重启 Claude Desktop,即可让 Claude 帮你收发微信消息!
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 🛠️ CLI 命令
|
|
66
|
+
|
|
67
|
+
| 命令 | 说明 |
|
|
68
|
+
|------|------|
|
|
69
|
+
| `login` | 扫码登录微信 |
|
|
70
|
+
| `status` | 查看账号和 daemon 状态 |
|
|
71
|
+
| `send <to> <text>` | 发送消息(支持短 ID) |
|
|
72
|
+
| `poll [--watch]` | 拉取消息 |
|
|
73
|
+
| `contacts` | 查看联系人 |
|
|
74
|
+
| `start [--webhook url]` | 启动 HTTP daemon |
|
|
75
|
+
| `stop` / `restart` | 停止/重启 daemon |
|
|
76
|
+
| `logs [-f]` | 查看日志 |
|
|
77
|
+
| `accounts list\|clean\|remove` | 管理账号 |
|
|
78
|
+
| `update` | 更新到最新版 |
|
|
79
|
+
|
|
80
|
+
### 短 ID 匹配
|
|
81
|
+
|
|
82
|
+
联系人唯一时,可用前缀代替完整 ID:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx weixin-mcp send abc12 "hello"
|
|
86
|
+
# ✓ Resolved "abc12" → abc123xyz456@im.wechat
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🔧 MCP 工具
|
|
67
92
|
|
|
68
|
-
|
|
93
|
+
| 工具 | 说明 | 参数 |
|
|
94
|
+
|------|------|------|
|
|
95
|
+
| `weixin_send` | 发送文本 | `to`, `text`, `context_token?` |
|
|
96
|
+
| `weixin_send_image` | 发送图片 | `to`, `source`, `caption?` |
|
|
97
|
+
| `weixin_send_file` | 发送文件 | `to`, `source`, `caption?` |
|
|
98
|
+
| `weixin_poll` | 拉取消息 | `reset_cursor?` |
|
|
99
|
+
| `weixin_contacts` | 联系人列表 | - |
|
|
100
|
+
| `weixin_get_config` | 获取配置 | `user_id` |
|
|
69
101
|
|
|
70
|
-
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 📡 Webhook 模式
|
|
105
|
+
|
|
106
|
+
实时接收消息推送:
|
|
71
107
|
|
|
72
108
|
```bash
|
|
73
|
-
npx weixin-mcp start --
|
|
109
|
+
npx weixin-mcp start --webhook http://your-server/weixin-hook
|
|
74
110
|
```
|
|
75
111
|
|
|
76
|
-
|
|
77
|
-
健康检查:`http://localhost:3001/health`
|
|
112
|
+
收到消息时 POST 到 webhook:
|
|
78
113
|
|
|
79
|
-
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"event": "weixin_messages",
|
|
117
|
+
"messages": [{
|
|
118
|
+
"from_user_id": "...",
|
|
119
|
+
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
|
120
|
+
"context_token": "..."
|
|
121
|
+
}],
|
|
122
|
+
"timestamp": "2026-03-22T19:00:00.000Z"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
80
127
|
|
|
81
|
-
|
|
82
|
-
|--------|------|------|
|
|
83
|
-
| `weixin_send` | 发送文本消息 | `to`、`text`、`context_token`(可选) |
|
|
84
|
-
| `weixin_poll` | 拉取新消息 | `reset_cursor`(可选) |
|
|
85
|
-
| `weixin_contacts` | 列出联系人 | 无 |
|
|
86
|
-
| `weixin_get_config` | 获取用户配置 | `user_id`、`context_token`(可选) |
|
|
128
|
+
## 🏠 数据存储
|
|
87
129
|
|
|
88
|
-
|
|
130
|
+
优先级:`$WEIXIN_MCP_DIR` > `~/.openclaw/openclaw-weixin/` > `~/.weixin-mcp/`
|
|
131
|
+
|
|
132
|
+
| 文件 | 说明 |
|
|
133
|
+
|------|------|
|
|
134
|
+
| `accounts/*.json` | 登录凭证 |
|
|
135
|
+
| `contacts.json` | 联系人 |
|
|
136
|
+
| `daemon.json` | Daemon 状态 |
|
|
137
|
+
| `daemon.log` | 日志 |
|
|
89
138
|
|
|
90
|
-
|
|
91
|
-
1. `WEIXIN_MCP_DIR` 环境变量
|
|
92
|
-
2. `~/.openclaw/openclaw-weixin/`(已装 OpenClaw)
|
|
93
|
-
3. `~/.weixin-mcp/`(默认)
|
|
139
|
+
---
|
|
94
140
|
|
|
95
|
-
|
|
96
|
-
- `accounts/<accountId>.json` — 账号 token
|
|
97
|
-
- `accounts/<accountId>.cursor.json` — 消息游标
|
|
98
|
-
- `contacts.json` — 联系人
|
|
99
|
-
- `daemon.json` — daemon PID(仅 HTTP 模式)
|
|
100
|
-
- `daemon.log` — daemon 日志
|
|
141
|
+
## 🔗 相关项目
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
- [OpenClaw](https://github.com/anthropics/openclaw) — AI Agent 基础设施
|
|
144
|
+
- [MCP Protocol](https://modelcontextprotocol.io/) — Model Context Protocol
|
|
145
|
+
- [ClawHub](https://clawhub.com/) — Agent Skills 市场
|
|
103
146
|
|
|
104
|
-
|
|
105
|
-
|--------|------|
|
|
106
|
-
| `WEIXIN_MCP_DIR` | 自定义数据目录 |
|
|
107
|
-
| `WEIXIN_ACCOUNT_ID` | 指定使用哪个账号 |
|
|
147
|
+
---
|
|
108
148
|
|
|
109
|
-
## License
|
|
149
|
+
## 📄 License
|
|
110
150
|
|
|
111
|
-
MIT
|
|
151
|
+
MIT © [bkmashiro](https://github.com/bkmashiro)
|
package/dist/api.d.ts
CHANGED
|
@@ -33,3 +33,23 @@ export declare function getConfig(ilinkUserId: string, token: string, baseUrl: s
|
|
|
33
33
|
* status: 1 = typing, 2 = cancel
|
|
34
34
|
*/
|
|
35
35
|
export declare function sendTyping(ilinkUserId: string, typingTicket: string, status: 1 | 2, token: string, baseUrl: string): Promise<unknown>;
|
|
36
|
+
export interface UploadedMedia {
|
|
37
|
+
filekey: string;
|
|
38
|
+
downloadEncryptedQueryParam: string;
|
|
39
|
+
aeskey: string;
|
|
40
|
+
fileSize: number;
|
|
41
|
+
fileSizeCiphertext: number;
|
|
42
|
+
fileName?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send an image message using a previously uploaded file.
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendImageMessage(to: string, uploaded: UploadedMedia, token: string, baseUrl: string, contextToken?: string, caption?: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Send a file attachment using a previously uploaded file.
|
|
50
|
+
*/
|
|
51
|
+
export declare function sendFileMessage(to: string, uploaded: UploadedMedia, token: string, baseUrl: string, contextToken?: string, caption?: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Send a video message using a previously uploaded file.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sendVideoMessage(to: string, uploaded: UploadedMedia, token: string, baseUrl: string, contextToken?: string, caption?: string): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -146,3 +146,106 @@ export async function sendTyping(ilinkUserId, typingTicket, status, token, baseU
|
|
|
146
146
|
base_info: { channel_version: CHANNEL_VERSION },
|
|
147
147
|
}, token, baseUrl);
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Send an image message using a previously uploaded file.
|
|
151
|
+
*/
|
|
152
|
+
export async function sendImageMessage(to, uploaded, token, baseUrl, contextToken, caption) {
|
|
153
|
+
const items = [];
|
|
154
|
+
if (caption)
|
|
155
|
+
items.push({ type: 1, text_item: { text: caption } });
|
|
156
|
+
items.push({
|
|
157
|
+
type: 2,
|
|
158
|
+
image_item: {
|
|
159
|
+
media: {
|
|
160
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
161
|
+
// Official SDK does Buffer.from(hexString).toString("base64") — hex as UTF-8 string
|
|
162
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
163
|
+
encrypt_type: 1,
|
|
164
|
+
},
|
|
165
|
+
aeskey: uploaded.aeskey, // hex string for client decryption
|
|
166
|
+
mid_size: uploaded.fileSizeCiphertext,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// Send each item separately (text caption + image)
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
await weixinRequest("ilink/bot/sendmessage", {
|
|
172
|
+
msg: {
|
|
173
|
+
from_user_id: "",
|
|
174
|
+
to_user_id: to,
|
|
175
|
+
client_id: generateClientId(),
|
|
176
|
+
message_type: 2,
|
|
177
|
+
message_state: 2,
|
|
178
|
+
item_list: [item],
|
|
179
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
180
|
+
},
|
|
181
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
182
|
+
}, token, baseUrl);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Send a file attachment using a previously uploaded file.
|
|
187
|
+
*/
|
|
188
|
+
export async function sendFileMessage(to, uploaded, token, baseUrl, contextToken, caption) {
|
|
189
|
+
const items = [];
|
|
190
|
+
if (caption)
|
|
191
|
+
items.push({ type: 1, text_item: { text: caption } });
|
|
192
|
+
items.push({
|
|
193
|
+
type: 4,
|
|
194
|
+
file_item: {
|
|
195
|
+
media: {
|
|
196
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
197
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
198
|
+
encrypt_type: 1,
|
|
199
|
+
},
|
|
200
|
+
file_name: uploaded.fileName ?? "file",
|
|
201
|
+
len: String(uploaded.fileSize),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
for (const item of items) {
|
|
205
|
+
await weixinRequest("ilink/bot/sendmessage", {
|
|
206
|
+
msg: {
|
|
207
|
+
from_user_id: "",
|
|
208
|
+
to_user_id: to,
|
|
209
|
+
client_id: generateClientId(),
|
|
210
|
+
message_type: 2,
|
|
211
|
+
message_state: 2,
|
|
212
|
+
item_list: [item],
|
|
213
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
214
|
+
},
|
|
215
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
216
|
+
}, token, baseUrl);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Send a video message using a previously uploaded file.
|
|
221
|
+
*/
|
|
222
|
+
export async function sendVideoMessage(to, uploaded, token, baseUrl, contextToken, caption) {
|
|
223
|
+
const items = [];
|
|
224
|
+
if (caption)
|
|
225
|
+
items.push({ type: 1, text_item: { text: caption } });
|
|
226
|
+
items.push({
|
|
227
|
+
type: 5,
|
|
228
|
+
video_item: {
|
|
229
|
+
media: {
|
|
230
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
231
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
232
|
+
encrypt_type: 1,
|
|
233
|
+
},
|
|
234
|
+
video_size: uploaded.fileSizeCiphertext,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
for (const item of items) {
|
|
238
|
+
await weixinRequest("ilink/bot/sendmessage", {
|
|
239
|
+
msg: {
|
|
240
|
+
from_user_id: "",
|
|
241
|
+
to_user_id: to,
|
|
242
|
+
client_id: generateClientId(),
|
|
243
|
+
message_type: 2,
|
|
244
|
+
message_state: 2,
|
|
245
|
+
item_list: [item],
|
|
246
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
247
|
+
},
|
|
248
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
249
|
+
}, token, baseUrl);
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/cdn.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN upload utilities for image/video/file sending.
|
|
3
|
+
* Based on @tencent-weixin/openclaw-weixin official implementation.
|
|
4
|
+
*/
|
|
5
|
+
export type MediaType = "image" | "video" | "file";
|
|
6
|
+
export interface UploadedMedia {
|
|
7
|
+
filekey: string;
|
|
8
|
+
downloadEncryptedQueryParam: string;
|
|
9
|
+
aeskey: string;
|
|
10
|
+
fileSize: number;
|
|
11
|
+
fileSizeCiphertext: number;
|
|
12
|
+
fileName?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Upload a file (local path or URL) to Weixin CDN.
|
|
16
|
+
* Returns UploadedMedia with all params needed for sendMessage.
|
|
17
|
+
*/
|
|
18
|
+
export declare function uploadMedia(params: {
|
|
19
|
+
source: string;
|
|
20
|
+
mediaType: MediaType;
|
|
21
|
+
toUserId: string;
|
|
22
|
+
token: string;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
}): Promise<UploadedMedia>;
|
package/dist/cdn.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN upload utilities for image/video/file sending.
|
|
3
|
+
* Based on @tencent-weixin/openclaw-weixin official implementation.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
9
|
+
// ── AES-128-ECB ────────────────────────────────────────────────────────────
|
|
10
|
+
function encryptAesEcb(plaintext, key) {
|
|
11
|
+
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
|
|
12
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
13
|
+
}
|
|
14
|
+
function aesEcbPaddedSize(plaintextSize) {
|
|
15
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
16
|
+
}
|
|
17
|
+
const MEDIA_TYPE_MAP = {
|
|
18
|
+
image: 1,
|
|
19
|
+
video: 2,
|
|
20
|
+
file: 3,
|
|
21
|
+
};
|
|
22
|
+
// ── API calls ──────────────────────────────────────────────────────────────
|
|
23
|
+
function randomWechatUin() {
|
|
24
|
+
return crypto.randomBytes(4).toString("base64");
|
|
25
|
+
}
|
|
26
|
+
async function getUploadUrl(params) {
|
|
27
|
+
const body = JSON.stringify({
|
|
28
|
+
filekey: params.filekey,
|
|
29
|
+
media_type: params.mediaType,
|
|
30
|
+
to_user_id: params.toUserId,
|
|
31
|
+
rawsize: params.rawsize,
|
|
32
|
+
rawfilemd5: params.rawfilemd5,
|
|
33
|
+
filesize: params.filesize,
|
|
34
|
+
no_need_thumb: true,
|
|
35
|
+
aeskey: params.aeskey,
|
|
36
|
+
base_info: { channel_version: "1.0.2" },
|
|
37
|
+
});
|
|
38
|
+
const res = await fetch(`${params.baseUrl}/ilink/bot/getuploadurl`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"Content-Length": String(Buffer.byteLength(body, "utf-8")),
|
|
43
|
+
"AuthorizationType": "ilink_bot_token",
|
|
44
|
+
"Authorization": `Bearer ${params.token}`,
|
|
45
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
46
|
+
},
|
|
47
|
+
body,
|
|
48
|
+
});
|
|
49
|
+
return res.json();
|
|
50
|
+
}
|
|
51
|
+
async function uploadToCdn(params) {
|
|
52
|
+
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
|
|
53
|
+
const cdnUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
|
|
54
|
+
const res = await fetch(cdnUrl, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
57
|
+
body: new Uint8Array(ciphertext),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
|
61
|
+
throw new Error(`CDN upload failed: ${errMsg}`);
|
|
62
|
+
}
|
|
63
|
+
const downloadParam = res.headers.get("x-encrypted-param");
|
|
64
|
+
if (!downloadParam) {
|
|
65
|
+
throw new Error("CDN response missing x-encrypted-param header");
|
|
66
|
+
}
|
|
67
|
+
return downloadParam;
|
|
68
|
+
}
|
|
69
|
+
// ── Main upload function ───────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* Upload a file (local path or URL) to Weixin CDN.
|
|
72
|
+
* Returns UploadedMedia with all params needed for sendMessage.
|
|
73
|
+
*/
|
|
74
|
+
export async function uploadMedia(params) {
|
|
75
|
+
const { source, mediaType, toUserId, token, baseUrl } = params;
|
|
76
|
+
// Load file
|
|
77
|
+
let plaintext;
|
|
78
|
+
let fileName;
|
|
79
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
80
|
+
// Download remote file
|
|
81
|
+
const res = await fetch(source);
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
throw new Error(`Failed to download: ${source}`);
|
|
84
|
+
plaintext = Buffer.from(await res.arrayBuffer());
|
|
85
|
+
// Extract filename from URL
|
|
86
|
+
const urlPath = new URL(source).pathname;
|
|
87
|
+
fileName = path.basename(urlPath);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Read local file
|
|
91
|
+
plaintext = await fs.readFile(source);
|
|
92
|
+
fileName = path.basename(source);
|
|
93
|
+
}
|
|
94
|
+
// Generate keys and hashes
|
|
95
|
+
const rawsize = plaintext.length;
|
|
96
|
+
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
97
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
98
|
+
const filekey = crypto.randomBytes(16).toString("hex");
|
|
99
|
+
const aeskey = crypto.randomBytes(16);
|
|
100
|
+
// Get upload URL
|
|
101
|
+
const uploadResp = await getUploadUrl({
|
|
102
|
+
filekey,
|
|
103
|
+
mediaType: MEDIA_TYPE_MAP[mediaType],
|
|
104
|
+
toUserId,
|
|
105
|
+
rawsize,
|
|
106
|
+
rawfilemd5,
|
|
107
|
+
filesize,
|
|
108
|
+
aeskey: aeskey.toString("hex"),
|
|
109
|
+
token,
|
|
110
|
+
baseUrl,
|
|
111
|
+
});
|
|
112
|
+
if (!uploadResp.upload_param) {
|
|
113
|
+
throw new Error(`getUploadUrl returned no upload_param: ${JSON.stringify(uploadResp)}`);
|
|
114
|
+
}
|
|
115
|
+
// Upload to CDN
|
|
116
|
+
const downloadEncryptedQueryParam = await uploadToCdn({
|
|
117
|
+
buf: plaintext,
|
|
118
|
+
uploadParam: uploadResp.upload_param,
|
|
119
|
+
filekey,
|
|
120
|
+
aeskey,
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
filekey,
|
|
124
|
+
downloadEncryptedQueryParam,
|
|
125
|
+
aeskey: aeskey.toString("hex"),
|
|
126
|
+
fileSize: rawsize,
|
|
127
|
+
fileSizeCiphertext: filesize,
|
|
128
|
+
fileName,
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
|
|
11
|
+
import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, sendImageMessage, sendFileMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
|
|
12
|
+
import { uploadMedia } from "./cdn.js";
|
|
12
13
|
import { ACCOUNTS_DIR } from "./paths.js";
|
|
13
14
|
import { updateContactsFromMsgs, loadContacts } from "./contacts.js";
|
|
14
15
|
/** Resolve short userId prefix to full ID from contacts. */
|
|
@@ -90,6 +91,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
90
91
|
description: "List users who have messaged the bot. Returns userId, lastSeen, lastText, contextToken, msgCount. Use userId as 'to' in weixin_send.",
|
|
91
92
|
inputSchema: { type: "object", properties: {} },
|
|
92
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: "weixin_send_image",
|
|
96
|
+
description: "Send an image to a WeChat user. Source can be a local file path or URL. Optionally include a text caption.",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
to: { type: "string", description: "Recipient user ID (full or short prefix)" },
|
|
101
|
+
source: { type: "string", description: "Image source: local file path or URL" },
|
|
102
|
+
caption: { type: "string", description: "Optional text caption to send with the image" },
|
|
103
|
+
context_token: { type: "string", description: "Optional context_token to link reply" },
|
|
104
|
+
},
|
|
105
|
+
required: ["to", "source"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "weixin_send_file",
|
|
110
|
+
description: "Send a file attachment to a WeChat user. Source can be a local file path or URL.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
to: { type: "string", description: "Recipient user ID (full or short prefix)" },
|
|
115
|
+
source: { type: "string", description: "File source: local file path or URL" },
|
|
116
|
+
caption: { type: "string", description: "Optional text caption to send with the file" },
|
|
117
|
+
context_token: { type: "string", description: "Optional context_token to link reply" },
|
|
118
|
+
},
|
|
119
|
+
required: ["to", "source"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
93
122
|
{
|
|
94
123
|
name: "weixin_get_config",
|
|
95
124
|
description: "Get bot config for a user — includes typing_ticket needed for sendTyping. Call before sending typing indicators.",
|
|
@@ -131,6 +160,36 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
131
160
|
else if (name === "weixin_contacts") {
|
|
132
161
|
result = Object.values(loadContacts());
|
|
133
162
|
}
|
|
163
|
+
else if (name === "weixin_send_image") {
|
|
164
|
+
const { to, source, caption, context_token } = (args ?? {});
|
|
165
|
+
const validatedTo = assertNonEmptyString(to, "to");
|
|
166
|
+
const resolvedTo = resolveUserId(validatedTo, loadContacts());
|
|
167
|
+
const validatedSource = assertNonEmptyString(source, "source");
|
|
168
|
+
const uploaded = await uploadMedia({
|
|
169
|
+
source: validatedSource,
|
|
170
|
+
mediaType: "image",
|
|
171
|
+
toUserId: resolvedTo,
|
|
172
|
+
token: token,
|
|
173
|
+
baseUrl,
|
|
174
|
+
});
|
|
175
|
+
await sendImageMessage(resolvedTo, uploaded, token, baseUrl, context_token, caption);
|
|
176
|
+
result = { success: true, filekey: uploaded.filekey };
|
|
177
|
+
}
|
|
178
|
+
else if (name === "weixin_send_file") {
|
|
179
|
+
const { to, source, caption, context_token } = (args ?? {});
|
|
180
|
+
const validatedTo = assertNonEmptyString(to, "to");
|
|
181
|
+
const resolvedTo = resolveUserId(validatedTo, loadContacts());
|
|
182
|
+
const validatedSource = assertNonEmptyString(source, "source");
|
|
183
|
+
const uploaded = await uploadMedia({
|
|
184
|
+
source: validatedSource,
|
|
185
|
+
mediaType: "file",
|
|
186
|
+
toUserId: resolvedTo,
|
|
187
|
+
token: token,
|
|
188
|
+
baseUrl,
|
|
189
|
+
});
|
|
190
|
+
await sendFileMessage(resolvedTo, uploaded, token, baseUrl, context_token, caption);
|
|
191
|
+
result = { success: true, filekey: uploaded.filekey, fileName: uploaded.fileName };
|
|
192
|
+
}
|
|
134
193
|
else if (name === "weixin_get_config") {
|
|
135
194
|
const { user_id, context_token } = (args ?? {});
|
|
136
195
|
const validatedUserId = assertNonEmptyString(user_id, "user_id");
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -202,3 +202,156 @@ export async function sendTyping(
|
|
|
202
202
|
baseUrl,
|
|
203
203
|
);
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
// ── Media message types ────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface UploadedMedia {
|
|
209
|
+
filekey: string;
|
|
210
|
+
downloadEncryptedQueryParam: string;
|
|
211
|
+
aeskey: string;
|
|
212
|
+
fileSize: number;
|
|
213
|
+
fileSizeCiphertext: number;
|
|
214
|
+
fileName?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Send an image message using a previously uploaded file.
|
|
219
|
+
*/
|
|
220
|
+
export async function sendImageMessage(
|
|
221
|
+
to: string,
|
|
222
|
+
uploaded: UploadedMedia,
|
|
223
|
+
token: string,
|
|
224
|
+
baseUrl: string,
|
|
225
|
+
contextToken?: string,
|
|
226
|
+
caption?: string,
|
|
227
|
+
) {
|
|
228
|
+
const items: Array<{ type: number; text_item?: { text: string }; image_item?: unknown }> = [];
|
|
229
|
+
if (caption) items.push({ type: 1, text_item: { text: caption } });
|
|
230
|
+
items.push({
|
|
231
|
+
type: 2,
|
|
232
|
+
image_item: {
|
|
233
|
+
media: {
|
|
234
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
235
|
+
// Official SDK does Buffer.from(hexString).toString("base64") — hex as UTF-8 string
|
|
236
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
237
|
+
encrypt_type: 1,
|
|
238
|
+
},
|
|
239
|
+
aeskey: uploaded.aeskey, // hex string for client decryption
|
|
240
|
+
mid_size: uploaded.fileSizeCiphertext,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Send each item separately (text caption + image)
|
|
245
|
+
for (const item of items) {
|
|
246
|
+
await weixinRequest(
|
|
247
|
+
"ilink/bot/sendmessage",
|
|
248
|
+
{
|
|
249
|
+
msg: {
|
|
250
|
+
from_user_id: "",
|
|
251
|
+
to_user_id: to,
|
|
252
|
+
client_id: generateClientId(),
|
|
253
|
+
message_type: 2,
|
|
254
|
+
message_state: 2,
|
|
255
|
+
item_list: [item],
|
|
256
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
257
|
+
},
|
|
258
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
259
|
+
},
|
|
260
|
+
token,
|
|
261
|
+
baseUrl,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Send a file attachment using a previously uploaded file.
|
|
268
|
+
*/
|
|
269
|
+
export async function sendFileMessage(
|
|
270
|
+
to: string,
|
|
271
|
+
uploaded: UploadedMedia,
|
|
272
|
+
token: string,
|
|
273
|
+
baseUrl: string,
|
|
274
|
+
contextToken?: string,
|
|
275
|
+
caption?: string,
|
|
276
|
+
) {
|
|
277
|
+
const items: Array<{ type: number; text_item?: { text: string }; file_item?: unknown }> = [];
|
|
278
|
+
if (caption) items.push({ type: 1, text_item: { text: caption } });
|
|
279
|
+
items.push({
|
|
280
|
+
type: 4,
|
|
281
|
+
file_item: {
|
|
282
|
+
media: {
|
|
283
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
284
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
285
|
+
encrypt_type: 1,
|
|
286
|
+
},
|
|
287
|
+
file_name: uploaded.fileName ?? "file",
|
|
288
|
+
len: String(uploaded.fileSize),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
await weixinRequest(
|
|
294
|
+
"ilink/bot/sendmessage",
|
|
295
|
+
{
|
|
296
|
+
msg: {
|
|
297
|
+
from_user_id: "",
|
|
298
|
+
to_user_id: to,
|
|
299
|
+
client_id: generateClientId(),
|
|
300
|
+
message_type: 2,
|
|
301
|
+
message_state: 2,
|
|
302
|
+
item_list: [item],
|
|
303
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
304
|
+
},
|
|
305
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
306
|
+
},
|
|
307
|
+
token,
|
|
308
|
+
baseUrl,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Send a video message using a previously uploaded file.
|
|
315
|
+
*/
|
|
316
|
+
export async function sendVideoMessage(
|
|
317
|
+
to: string,
|
|
318
|
+
uploaded: UploadedMedia,
|
|
319
|
+
token: string,
|
|
320
|
+
baseUrl: string,
|
|
321
|
+
contextToken?: string,
|
|
322
|
+
caption?: string,
|
|
323
|
+
) {
|
|
324
|
+
const items: Array<{ type: number; text_item?: { text: string }; video_item?: unknown }> = [];
|
|
325
|
+
if (caption) items.push({ type: 1, text_item: { text: caption } });
|
|
326
|
+
items.push({
|
|
327
|
+
type: 5,
|
|
328
|
+
video_item: {
|
|
329
|
+
media: {
|
|
330
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
331
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
332
|
+
encrypt_type: 1,
|
|
333
|
+
},
|
|
334
|
+
video_size: uploaded.fileSizeCiphertext,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
for (const item of items) {
|
|
339
|
+
await weixinRequest(
|
|
340
|
+
"ilink/bot/sendmessage",
|
|
341
|
+
{
|
|
342
|
+
msg: {
|
|
343
|
+
from_user_id: "",
|
|
344
|
+
to_user_id: to,
|
|
345
|
+
client_id: generateClientId(),
|
|
346
|
+
message_type: 2,
|
|
347
|
+
message_state: 2,
|
|
348
|
+
item_list: [item],
|
|
349
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
350
|
+
},
|
|
351
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
352
|
+
},
|
|
353
|
+
token,
|
|
354
|
+
baseUrl,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
package/src/cdn.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN upload utilities for image/video/file sending.
|
|
3
|
+
* Based on @tencent-weixin/openclaw-weixin official implementation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
11
|
+
|
|
12
|
+
// ── AES-128-ECB ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
15
|
+
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
|
|
16
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function aesEcbPaddedSize(plaintextSize: number): number {
|
|
20
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type MediaType = "image" | "video" | "file";
|
|
26
|
+
|
|
27
|
+
const MEDIA_TYPE_MAP: Record<MediaType, number> = {
|
|
28
|
+
image: 1,
|
|
29
|
+
video: 2,
|
|
30
|
+
file: 3,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface UploadedMedia {
|
|
34
|
+
filekey: string;
|
|
35
|
+
downloadEncryptedQueryParam: string;
|
|
36
|
+
aeskey: string;
|
|
37
|
+
fileSize: number;
|
|
38
|
+
fileSizeCiphertext: number;
|
|
39
|
+
fileName?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── API calls ──────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function randomWechatUin(): string {
|
|
45
|
+
return crypto.randomBytes(4).toString("base64");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getUploadUrl(params: {
|
|
49
|
+
filekey: string;
|
|
50
|
+
mediaType: number;
|
|
51
|
+
toUserId: string;
|
|
52
|
+
rawsize: number;
|
|
53
|
+
rawfilemd5: string;
|
|
54
|
+
filesize: number;
|
|
55
|
+
aeskey: string;
|
|
56
|
+
token: string;
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
}): Promise<{ upload_param?: string; errcode?: number; errmsg?: string }> {
|
|
59
|
+
const body = JSON.stringify({
|
|
60
|
+
filekey: params.filekey,
|
|
61
|
+
media_type: params.mediaType,
|
|
62
|
+
to_user_id: params.toUserId,
|
|
63
|
+
rawsize: params.rawsize,
|
|
64
|
+
rawfilemd5: params.rawfilemd5,
|
|
65
|
+
filesize: params.filesize,
|
|
66
|
+
no_need_thumb: true,
|
|
67
|
+
aeskey: params.aeskey,
|
|
68
|
+
base_info: { channel_version: "1.0.2" },
|
|
69
|
+
});
|
|
70
|
+
const res = await fetch(`${params.baseUrl}/ilink/bot/getuploadurl`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
"Content-Length": String(Buffer.byteLength(body, "utf-8")),
|
|
75
|
+
"AuthorizationType": "ilink_bot_token",
|
|
76
|
+
"Authorization": `Bearer ${params.token}`,
|
|
77
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
78
|
+
},
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function uploadToCdn(params: {
|
|
85
|
+
buf: Buffer;
|
|
86
|
+
uploadParam: string;
|
|
87
|
+
filekey: string;
|
|
88
|
+
aeskey: Buffer;
|
|
89
|
+
}): Promise<string> {
|
|
90
|
+
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
|
|
91
|
+
const cdnUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
|
|
92
|
+
|
|
93
|
+
const res = await fetch(cdnUrl, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
96
|
+
body: new Uint8Array(ciphertext),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
|
101
|
+
throw new Error(`CDN upload failed: ${errMsg}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const downloadParam = res.headers.get("x-encrypted-param");
|
|
105
|
+
if (!downloadParam) {
|
|
106
|
+
throw new Error("CDN response missing x-encrypted-param header");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return downloadParam;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Main upload function ───────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Upload a file (local path or URL) to Weixin CDN.
|
|
116
|
+
* Returns UploadedMedia with all params needed for sendMessage.
|
|
117
|
+
*/
|
|
118
|
+
export async function uploadMedia(params: {
|
|
119
|
+
source: string; // file path or URL
|
|
120
|
+
mediaType: MediaType;
|
|
121
|
+
toUserId: string;
|
|
122
|
+
token: string;
|
|
123
|
+
baseUrl: string;
|
|
124
|
+
}): Promise<UploadedMedia> {
|
|
125
|
+
const { source, mediaType, toUserId, token, baseUrl } = params;
|
|
126
|
+
|
|
127
|
+
// Load file
|
|
128
|
+
let plaintext: Buffer;
|
|
129
|
+
let fileName: string | undefined;
|
|
130
|
+
|
|
131
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
132
|
+
// Download remote file
|
|
133
|
+
const res = await fetch(source);
|
|
134
|
+
if (!res.ok) throw new Error(`Failed to download: ${source}`);
|
|
135
|
+
plaintext = Buffer.from(await res.arrayBuffer());
|
|
136
|
+
// Extract filename from URL
|
|
137
|
+
const urlPath = new URL(source).pathname;
|
|
138
|
+
fileName = path.basename(urlPath);
|
|
139
|
+
} else {
|
|
140
|
+
// Read local file
|
|
141
|
+
plaintext = await fs.readFile(source);
|
|
142
|
+
fileName = path.basename(source);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Generate keys and hashes
|
|
146
|
+
const rawsize = plaintext.length;
|
|
147
|
+
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
148
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
149
|
+
const filekey = crypto.randomBytes(16).toString("hex");
|
|
150
|
+
const aeskey = crypto.randomBytes(16);
|
|
151
|
+
|
|
152
|
+
// Get upload URL
|
|
153
|
+
const uploadResp = await getUploadUrl({
|
|
154
|
+
filekey,
|
|
155
|
+
mediaType: MEDIA_TYPE_MAP[mediaType],
|
|
156
|
+
toUserId,
|
|
157
|
+
rawsize,
|
|
158
|
+
rawfilemd5,
|
|
159
|
+
filesize,
|
|
160
|
+
aeskey: aeskey.toString("hex"),
|
|
161
|
+
token,
|
|
162
|
+
baseUrl,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!uploadResp.upload_param) {
|
|
166
|
+
throw new Error(`getUploadUrl returned no upload_param: ${JSON.stringify(uploadResp)}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Upload to CDN
|
|
170
|
+
const downloadEncryptedQueryParam = await uploadToCdn({
|
|
171
|
+
buf: plaintext,
|
|
172
|
+
uploadParam: uploadResp.upload_param,
|
|
173
|
+
filekey,
|
|
174
|
+
aeskey,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
filekey,
|
|
179
|
+
downloadEncryptedQueryParam,
|
|
180
|
+
aeskey: aeskey.toString("hex"),
|
|
181
|
+
fileSize: rawsize,
|
|
182
|
+
fileSizeCiphertext: filesize,
|
|
183
|
+
fileName,
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,11 +17,14 @@ import {
|
|
|
17
17
|
getUpdates,
|
|
18
18
|
getConfig,
|
|
19
19
|
sendTextMessage,
|
|
20
|
+
sendImageMessage,
|
|
21
|
+
sendFileMessage,
|
|
20
22
|
loadCursor,
|
|
21
23
|
saveCursor,
|
|
22
24
|
WeixinAuthError,
|
|
23
25
|
WeixinNetworkError,
|
|
24
26
|
} from "./api.js";
|
|
27
|
+
import { uploadMedia } from "./cdn.js";
|
|
25
28
|
import { ACCOUNTS_DIR } from "./paths.js";
|
|
26
29
|
import { updateContactsFromMsgs, loadContacts, type ContactBook } from "./contacts.js";
|
|
27
30
|
|
|
@@ -126,6 +129,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
126
129
|
"List users who have messaged the bot. Returns userId, lastSeen, lastText, contextToken, msgCount. Use userId as 'to' in weixin_send.",
|
|
127
130
|
inputSchema: { type: "object", properties: {} },
|
|
128
131
|
},
|
|
132
|
+
{
|
|
133
|
+
name: "weixin_send_image",
|
|
134
|
+
description:
|
|
135
|
+
"Send an image to a WeChat user. Source can be a local file path or URL. Optionally include a text caption.",
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
to: { type: "string", description: "Recipient user ID (full or short prefix)" },
|
|
140
|
+
source: { type: "string", description: "Image source: local file path or URL" },
|
|
141
|
+
caption: { type: "string", description: "Optional text caption to send with the image" },
|
|
142
|
+
context_token: { type: "string", description: "Optional context_token to link reply" },
|
|
143
|
+
},
|
|
144
|
+
required: ["to", "source"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "weixin_send_file",
|
|
149
|
+
description:
|
|
150
|
+
"Send a file attachment to a WeChat user. Source can be a local file path or URL.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
to: { type: "string", description: "Recipient user ID (full or short prefix)" },
|
|
155
|
+
source: { type: "string", description: "File source: local file path or URL" },
|
|
156
|
+
caption: { type: "string", description: "Optional text caption to send with the file" },
|
|
157
|
+
context_token: { type: "string", description: "Optional context_token to link reply" },
|
|
158
|
+
},
|
|
159
|
+
required: ["to", "source"],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
129
162
|
{
|
|
130
163
|
name: "weixin_get_config",
|
|
131
164
|
description:
|
|
@@ -177,6 +210,44 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
177
210
|
result = resp;
|
|
178
211
|
} else if (name === "weixin_contacts") {
|
|
179
212
|
result = Object.values(loadContacts());
|
|
213
|
+
} else if (name === "weixin_send_image") {
|
|
214
|
+
const { to, source, caption, context_token } = (args ?? {}) as {
|
|
215
|
+
to?: string;
|
|
216
|
+
source?: string;
|
|
217
|
+
caption?: string;
|
|
218
|
+
context_token?: string;
|
|
219
|
+
};
|
|
220
|
+
const validatedTo = assertNonEmptyString(to, "to");
|
|
221
|
+
const resolvedTo = resolveUserId(validatedTo, loadContacts());
|
|
222
|
+
const validatedSource = assertNonEmptyString(source, "source");
|
|
223
|
+
const uploaded = await uploadMedia({
|
|
224
|
+
source: validatedSource,
|
|
225
|
+
mediaType: "image",
|
|
226
|
+
toUserId: resolvedTo,
|
|
227
|
+
token: token!,
|
|
228
|
+
baseUrl,
|
|
229
|
+
});
|
|
230
|
+
await sendImageMessage(resolvedTo, uploaded, token!, baseUrl, context_token, caption);
|
|
231
|
+
result = { success: true, filekey: uploaded.filekey };
|
|
232
|
+
} else if (name === "weixin_send_file") {
|
|
233
|
+
const { to, source, caption, context_token } = (args ?? {}) as {
|
|
234
|
+
to?: string;
|
|
235
|
+
source?: string;
|
|
236
|
+
caption?: string;
|
|
237
|
+
context_token?: string;
|
|
238
|
+
};
|
|
239
|
+
const validatedTo = assertNonEmptyString(to, "to");
|
|
240
|
+
const resolvedTo = resolveUserId(validatedTo, loadContacts());
|
|
241
|
+
const validatedSource = assertNonEmptyString(source, "source");
|
|
242
|
+
const uploaded = await uploadMedia({
|
|
243
|
+
source: validatedSource,
|
|
244
|
+
mediaType: "file",
|
|
245
|
+
toUserId: resolvedTo,
|
|
246
|
+
token: token!,
|
|
247
|
+
baseUrl,
|
|
248
|
+
});
|
|
249
|
+
await sendFileMessage(resolvedTo, uploaded, token!, baseUrl, context_token, caption);
|
|
250
|
+
result = { success: true, filekey: uploaded.filekey, fileName: uploaded.fileName };
|
|
180
251
|
} else if (name === "weixin_get_config") {
|
|
181
252
|
const { user_id, context_token } = (args ?? {}) as {
|
|
182
253
|
user_id?: string;
|