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 CHANGED
@@ -1,54 +1,51 @@
1
1
  # weixin-mcp
2
2
 
3
- MCP server for WeChat messaging — expose WeChat capabilities as MCP tools for Claude Desktop, Cursor, and other MCP clients.
3
+ [![npm version](https://img.shields.io/npm/v/weixin-mcp.svg)](https://www.npmjs.com/package/weixin-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- [中文](./README.md)
6
+ 🤖 **WeChat MCP Server** — Let AI assistants send and receive WeChat messages
6
7
 
7
- ## Quick Start
8
+ Expose WeChat capabilities as [MCP](https://modelcontextprotocol.io/) tools. Claude Desktop, Cursor, OpenClaw, and other AI assistants can directly:
8
9
 
9
- ```bash
10
- # 1. Login (scan QR code)
11
- npx weixin-mcp login
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
- # 2. Check status
14
- npx weixin-mcp status
14
+ [中文](./README.md) | [ClawHub Skill](https://clawhub.com/skills/weixin-mcp)
15
15
 
16
- # 3. Start MCP server (stdio mode for Claude Desktop)
17
- npx weixin-mcp
18
- ```
16
+ ---
19
17
 
20
- ## CLI Commands
18
+ ## Features
21
19
 
22
- | Command | Description |
23
- |---------|-------------|
24
- | `npx weixin-mcp login` | QR code login |
25
- | `npx weixin-mcp status` | Show account and daemon status |
26
- | `npx weixin-mcp` | Start stdio MCP server (Claude Desktop) |
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
- ### Short ID Matching
26
+ ---
41
27
 
42
- When sending messages, you can use a prefix of the user ID if it uniquely matches a contact:
28
+ ## 🚀 Quick Start
43
29
 
44
30
  ```bash
45
- npx weixin-mcp send o9cq8 "hello"
46
- # Resolved "o9cq8" → o9cq80x8ou646cs3Tt5EQgfsZRtI@im.wechat
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
- ## Claude Desktop Integration
44
+ ---
45
+
46
+ ## 🔌 Claude Desktop Integration
50
47
 
51
- Add to `claude_desktop_config.json`:
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
- ## HTTP Daemon Mode
61
+ Restart Claude Desktop. Now Claude can send and receive WeChat messages for you!
62
+
63
+ ---
65
64
 
66
- Start an HTTP daemon for multi-client connections:
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 start --port 3001
85
+ npx weixin-mcp send abc12 "hello"
86
+ # ✓ Resolved "abc12" → abc123xyz456@im.wechat
70
87
  ```
71
88
 
72
- - MCP endpoint: `http://localhost:3001/mcp` (StreamableHTTP)
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 message | `to`, `text`, `context_token` (optional) |
80
- | `weixin_poll` | Poll new messages | `reset_cursor` (optional) |
81
- | `weixin_contacts` | List contacts | none |
82
- | `weixin_get_config` | Get user config | `user_id`, `context_token` (optional) |
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
- ## Data Storage
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
- Priority:
87
- 1. `WEIXIN_MCP_DIR` environment variable
88
- 2. `~/.openclaw/openclaw-weixin/` (if OpenClaw installed)
89
- 3. `~/.weixin-mcp/` (default)
139
+ ---
90
140
 
91
- Files:
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
- ## Environment Variables
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
- | Variable | Description |
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
- 基于 MCP 协议的微信消息服务端——将微信能力暴露为 MCP 工具,供 Claude Desktop、Cursor 及其他 MCP 客户端使用。
3
+ [![npm version](https://img.shields.io/npm/v/weixin-mcp.svg)](https://www.npmjs.com/package/weixin-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- 支持复用 [OpenClaw weixin 插件](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) 的已有登录态,或独立扫码登录。
6
+ 🤖 **微信 MCP Server** — 让 AI 助手直接收发微信消息
6
7
 
7
- [English](./README.en.md)
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. 启动 MCP server(Claude Desktop 模式)
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
- ## CLI 命令一览
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
- ### 短 ID 匹配
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
- ## HTTP Daemon 模式
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
- 除了 stdio 模式,也可以启动 HTTP daemon 供多客户端连接:
102
+ ---
103
+
104
+ ## 📡 Webhook 模式
105
+
106
+ 实时接收消息推送:
71
107
 
72
108
  ```bash
73
- npx weixin-mcp start --port 3001
109
+ npx weixin-mcp start --webhook http://your-server/weixin-hook
74
110
  ```
75
111
 
76
- MCP 端点:`http://localhost:3001/mcp`(StreamableHTTP)
77
- 健康检查:`http://localhost:3001/health`
112
+ 收到消息时 POST 到 webhook:
78
113
 
79
- ## MCP 工具列表
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weixin-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "MCP server for WeChat (Weixin) — send messages via OpenClaw weixin plugin auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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;