weixin-mcp 1.5.0 → 1.7.0

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
@@ -42,8 +42,8 @@ npx weixin-mcp
42
42
  When sending messages, you can use a prefix of the user ID if it uniquely matches a contact:
43
43
 
44
44
  ```bash
45
- npx weixin-mcp send o9cq8 "hello"
46
- # Resolved "o9cq8" → o9cq80x8ou646cs3Tt5EQgfsZRtI@im.wechat
45
+ npx weixin-mcp send abc12 "hello"
46
+ # Resolved "abc12" → abc123xyz456@im.wechat
47
47
  ```
48
48
 
49
49
  ## Claude Desktop Integration
package/README.md CHANGED
@@ -44,8 +44,8 @@ npx weixin-mcp
44
44
  发送消息时可以用用户 ID 的前缀,只要在联系人中唯一匹配即可:
45
45
 
46
46
  ```bash
47
- npx weixin-mcp send o9cq8 "hello"
48
- # Resolved "o9cq8" → o9cq80x8ou646cs3Tt5EQgfsZRtI@im.wechat
47
+ npx weixin-mcp send abc12 "hello"
48
+ # Resolved "abc12" → abc123xyz456@im.wechat
49
49
  ```
50
50
 
51
51
  ## Claude Desktop 集成
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/cli.js CHANGED
@@ -32,8 +32,10 @@ else if (command === "status") {
32
32
  else if (command === "start") {
33
33
  const portArg = process.argv.indexOf("--port");
34
34
  const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
35
+ const webhookArg = process.argv.indexOf("--webhook");
36
+ const webhook = webhookArg !== -1 ? process.argv[webhookArg + 1] : undefined;
35
37
  const { startDaemon } = await import("./daemon.js");
36
- await startDaemon(port);
38
+ await startDaemon(port, webhook);
37
39
  }
38
40
  else if (command === "stop") {
39
41
  const { stopDaemon } = await import("./daemon.js");
@@ -113,7 +115,7 @@ Commands:
113
115
  (no args) Start stdio MCP server (Claude Desktop mode)
114
116
  login QR code login
115
117
  status Show account and daemon status
116
- start [--port n] Start HTTP MCP daemon in background (default: 3001)
118
+ start [--port n] [--webhook url] Start HTTP daemon (with optional webhook push)
117
119
  stop Stop daemon
118
120
  restart Restart daemon
119
121
  logs [-f] Show daemon logs (-f to follow)
package/dist/daemon.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function daemonStatus(): {
13
13
  running: boolean;
14
14
  info: DaemonInfo | null;
15
15
  };
16
- export declare function startDaemon(port?: number): Promise<void>;
16
+ export declare function startDaemon(port?: number, webhook?: string): Promise<void>;
17
17
  export declare function stopDaemon(): void;
18
18
  export declare function restartDaemon(port?: number): Promise<void>;
19
19
  export declare function showLogs(follow?: boolean): void;
package/dist/daemon.js CHANGED
@@ -44,7 +44,7 @@ export function daemonStatus() {
44
44
  }
45
45
  return { running, info: running ? info : null };
46
46
  }
47
- export async function startDaemon(port = DEFAULT_PORT) {
47
+ export async function startDaemon(port = DEFAULT_PORT, webhook) {
48
48
  const { running, info } = daemonStatus();
49
49
  if (running && info) {
50
50
  console.log(`⚠️ Daemon already running (pid ${info.pid}, port ${info.port})`);
@@ -55,10 +55,13 @@ export async function startDaemon(port = DEFAULT_PORT) {
55
55
  const __dirname = path.dirname(__filename);
56
56
  const serverScript = path.join(__dirname, "server-http.js");
57
57
  const logFd = fs.openSync(LOG_FILE, "a");
58
- const child = spawn(process.execPath, [serverScript, String(port)], {
58
+ const serverArgs = ["--port", String(port)];
59
+ if (webhook)
60
+ serverArgs.push("--webhook", webhook);
61
+ const child = spawn(process.execPath, [serverScript, ...serverArgs], {
59
62
  detached: true,
60
63
  stdio: ["ignore", logFd, logFd],
61
- env: { ...process.env, WEIXIN_MCP_PORT: String(port) },
64
+ env: { ...process.env, WEIXIN_MCP_PORT: String(port), WEIXIN_WEBHOOK_URL: webhook ?? "" },
62
65
  });
63
66
  child.unref();
64
67
  fs.closeSync(logFd);
@@ -79,6 +82,8 @@ export async function startDaemon(port = DEFAULT_PORT) {
79
82
  console.log(` PID: ${child.pid}`);
80
83
  console.log(` Port: ${port}`);
81
84
  console.log(` URL: http://localhost:${port}/mcp`);
85
+ if (webhook)
86
+ console.log(` Webhook: ${webhook}`);
82
87
  console.log(` Logs: ${LOG_FILE}`);
83
88
  }
84
89
  export function stopDaemon() {
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");
@@ -2,6 +2,10 @@
2
2
  * HTTP MCP server — runs as a daemon process.
3
3
  * Spawned by `weixin-mcp start`, listens on a given port.
4
4
  *
5
- * Clients connect via: http://localhost:<port>/mcp
5
+ * Features:
6
+ * - MCP endpoint at /mcp (StreamableHTTP)
7
+ * - Health check at /health
8
+ * - Webhook push: --webhook <url> to receive new messages via POST
9
+ * - Auto-poll: when webhook is set, background polling forwards messages
6
10
  */
7
11
  export {};
@@ -2,19 +2,28 @@
2
2
  * HTTP MCP server — runs as a daemon process.
3
3
  * Spawned by `weixin-mcp start`, listens on a given port.
4
4
  *
5
- * Clients connect via: http://localhost:<port>/mcp
5
+ * Features:
6
+ * - MCP endpoint at /mcp (StreamableHTTP)
7
+ * - Health check at /health
8
+ * - Webhook push: --webhook <url> to receive new messages via POST
9
+ * - Auto-poll: when webhook is set, background polling forwards messages
6
10
  */
7
11
  import express from "express";
8
12
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
13
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
14
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
11
15
  import { randomUUID } from "node:crypto";
12
- // Reuse the same tool definitions and handlers from the main server
13
16
  import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
14
17
  import { ACCOUNTS_DIR } from "./paths.js";
18
+ import { updateContactsFromMsgs, loadContacts } from "./contacts.js";
15
19
  import fs from "node:fs";
16
20
  import path from "node:path";
17
- const port = Number(process.env.WEIXIN_MCP_PORT ?? process.argv[2] ?? 3001);
21
+ // Parse CLI args
22
+ const args = process.argv.slice(2);
23
+ const portIdx = args.indexOf("--port");
24
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : Number(process.env.WEIXIN_MCP_PORT ?? 3001);
25
+ const webhookIdx = args.indexOf("--webhook");
26
+ const webhookUrl = webhookIdx >= 0 ? args[webhookIdx + 1] : process.env.WEIXIN_WEBHOOK_URL;
18
27
  function loadAccount() {
19
28
  const files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
20
29
  if (files.length === 0)
@@ -35,9 +44,18 @@ function fmtErr(e) {
35
44
  return e.message;
36
45
  return String(e);
37
46
  }
47
+ function resolveUserId(input, contacts) {
48
+ if (!input || input.includes("@"))
49
+ return input;
50
+ const ids = Object.keys(contacts);
51
+ const matches = ids.filter((id) => id.startsWith(input) || id.includes(input));
52
+ if (matches.length === 1)
53
+ return matches[0];
54
+ return input;
55
+ }
38
56
  // ── MCP server factory ─────────────────────────────────────────────────────
39
57
  function createMCPServer() {
40
- const server = new Server({ name: "weixin-mcp", version: "1.2.2" }, { capabilities: { tools: {} } });
58
+ const server = new Server({ name: "weixin-mcp", version: "1.5.0" }, { capabilities: { tools: {} } });
41
59
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
42
60
  tools: [
43
61
  {
@@ -46,7 +64,7 @@ function createMCPServer() {
46
64
  inputSchema: {
47
65
  type: "object",
48
66
  properties: {
49
- to: { type: "string" },
67
+ to: { type: "string", description: "Recipient (full ID or short prefix)" },
50
68
  text: { type: "string" },
51
69
  context_token: { type: "string" },
52
70
  },
@@ -61,6 +79,11 @@ function createMCPServer() {
61
79
  properties: { reset_cursor: { type: "boolean" } },
62
80
  },
63
81
  },
82
+ {
83
+ name: "weixin_contacts",
84
+ description: "List users who have messaged the bot.",
85
+ inputSchema: { type: "object", properties: {} },
86
+ },
64
87
  {
65
88
  name: "weixin_get_config",
66
89
  description: "Get user config (typing ticket, etc.).",
@@ -82,7 +105,8 @@ function createMCPServer() {
82
105
  let result;
83
106
  if (name === "weixin_send") {
84
107
  const a = (args ?? {});
85
- result = await sendTextMessage(assertStr(a.to, "to"), assertStr(a.text, "text"), token, baseUrl, a.context_token);
108
+ const resolvedTo = resolveUserId(assertStr(a.to, "to"), loadContacts());
109
+ result = await sendTextMessage(resolvedTo, assertStr(a.text, "text"), token, baseUrl, a.context_token);
86
110
  }
87
111
  else if (name === "weixin_poll") {
88
112
  const { reset_cursor } = (args ?? {});
@@ -90,8 +114,13 @@ function createMCPServer() {
90
114
  const resp = await getUpdates(token, baseUrl, cursor);
91
115
  if (resp.get_updates_buf)
92
116
  saveCursor(accountId, resp.get_updates_buf);
117
+ if (resp.msgs && resp.msgs.length > 0)
118
+ updateContactsFromMsgs(resp.msgs);
93
119
  result = resp;
94
120
  }
121
+ else if (name === "weixin_contacts") {
122
+ result = Object.values(loadContacts());
123
+ }
95
124
  else if (name === "weixin_get_config") {
96
125
  const a = (args ?? {});
97
126
  result = await getConfig(assertStr(a.user_id, "user_id"), token, baseUrl, a.context_token);
@@ -107,17 +136,55 @@ function createMCPServer() {
107
136
  });
108
137
  return server;
109
138
  }
139
+ // ── Webhook push ───────────────────────────────────────────────────────────
140
+ async function pushToWebhook(msgs) {
141
+ if (!webhookUrl || msgs.length === 0)
142
+ return;
143
+ try {
144
+ await fetch(webhookUrl, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ event: "weixin_messages", messages: msgs, timestamp: new Date().toISOString() }),
148
+ });
149
+ }
150
+ catch (err) {
151
+ console.error("[weixin-mcp] webhook push failed:", fmtErr(err));
152
+ }
153
+ }
154
+ // ── Background poller (when webhook is set) ────────────────────────────────
155
+ async function startBackgroundPoller() {
156
+ if (!webhookUrl)
157
+ return;
158
+ console.log(`[weixin-mcp] Webhook enabled: ${webhookUrl}`);
159
+ console.log("[weixin-mcp] Starting background poller...");
160
+ while (true) {
161
+ try {
162
+ const { token, baseUrl = DEFAULT_BASE_URL, accountId } = loadAccount();
163
+ const cursor = loadCursor(accountId);
164
+ const resp = await getUpdates(token, baseUrl, cursor);
165
+ if (resp.get_updates_buf)
166
+ saveCursor(accountId, resp.get_updates_buf);
167
+ if (resp.msgs && resp.msgs.length > 0) {
168
+ updateContactsFromMsgs(resp.msgs);
169
+ await pushToWebhook(resp.msgs);
170
+ console.log(`[weixin-mcp] Pushed ${resp.msgs.length} message(s) to webhook`);
171
+ }
172
+ }
173
+ catch (err) {
174
+ console.error("[weixin-mcp] poll error:", fmtErr(err));
175
+ await new Promise((r) => setTimeout(r, 5000)); // backoff on error
176
+ }
177
+ // getUpdates is long-poll (~30s timeout), so no extra delay needed
178
+ }
179
+ }
110
180
  // ── Express HTTP server ────────────────────────────────────────────────────
111
181
  const app = express();
112
182
  app.use(express.json());
113
- // Session store for stateful transports
114
183
  const sessions = new Map();
115
184
  app.post("/mcp", async (req, res) => {
116
- // Check if this is an existing session
117
185
  const sessionId = req.headers["mcp-session-id"];
118
186
  let transport = sessionId ? sessions.get(sessionId) : undefined;
119
187
  if (!transport) {
120
- // New session
121
188
  const newSessionId = randomUUID();
122
189
  transport = new StreamableHTTPServerTransport({
123
190
  sessionIdGenerator: () => newSessionId,
@@ -148,9 +215,11 @@ app.delete("/mcp", async (req, res) => {
148
215
  await transport.handleRequest(req, res);
149
216
  });
150
217
  app.get("/health", (_req, res) => {
151
- res.json({ status: "ok", port, sessions: sessions.size });
218
+ res.json({ status: "ok", port, sessions: sessions.size, webhook: webhookUrl ?? null });
152
219
  });
153
220
  app.listen(port, () => {
154
- console.log(`[weixin-mcp] HTTP MCP server listening on port ${port}`);
155
- console.log(`[weixin-mcp] MCP endpoint: http://localhost:${port}/mcp`);
221
+ console.log(`[weixin-mcp] HTTP MCP server on port ${port}`);
222
+ console.log(`[weixin-mcp] MCP: http://localhost:${port}/mcp`);
223
+ if (webhookUrl)
224
+ startBackgroundPoller();
156
225
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weixin-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
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/cli.ts CHANGED
@@ -35,8 +35,10 @@ if (command === "login") {
35
35
  } else if (command === "start") {
36
36
  const portArg = process.argv.indexOf("--port");
37
37
  const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
38
+ const webhookArg = process.argv.indexOf("--webhook");
39
+ const webhook = webhookArg !== -1 ? process.argv[webhookArg + 1] : undefined;
38
40
  const { startDaemon } = await import("./daemon.js");
39
- await startDaemon(port);
41
+ await startDaemon(port, webhook);
40
42
 
41
43
  } else if (command === "stop") {
42
44
  const { stopDaemon } = await import("./daemon.js");
@@ -115,7 +117,7 @@ Commands:
115
117
  (no args) Start stdio MCP server (Claude Desktop mode)
116
118
  login QR code login
117
119
  status Show account and daemon status
118
- start [--port n] Start HTTP MCP daemon in background (default: 3001)
120
+ start [--port n] [--webhook url] Start HTTP daemon (with optional webhook push)
119
121
  stop Stop daemon
120
122
  restart Restart daemon
121
123
  logs [-f] Show daemon logs (-f to follow)
package/src/daemon.ts CHANGED
@@ -50,7 +50,7 @@ export function daemonStatus(): { running: boolean; info: DaemonInfo | null } {
50
50
  return { running, info: running ? info : null };
51
51
  }
52
52
 
53
- export async function startDaemon(port = DEFAULT_PORT): Promise<void> {
53
+ export async function startDaemon(port = DEFAULT_PORT, webhook?: string): Promise<void> {
54
54
  const { running, info } = daemonStatus();
55
55
  if (running && info) {
56
56
  console.log(`⚠️ Daemon already running (pid ${info.pid}, port ${info.port})`);
@@ -64,10 +64,13 @@ export async function startDaemon(port = DEFAULT_PORT): Promise<void> {
64
64
  const serverScript = path.join(__dirname, "server-http.js");
65
65
 
66
66
  const logFd = fs.openSync(LOG_FILE, "a");
67
- const child = spawn(process.execPath, [serverScript, String(port)], {
67
+ const serverArgs = ["--port", String(port)];
68
+ if (webhook) serverArgs.push("--webhook", webhook);
69
+
70
+ const child = spawn(process.execPath, [serverScript, ...serverArgs], {
68
71
  detached: true,
69
72
  stdio: ["ignore", logFd, logFd],
70
- env: { ...process.env, WEIXIN_MCP_PORT: String(port) },
73
+ env: { ...process.env, WEIXIN_MCP_PORT: String(port), WEIXIN_WEBHOOK_URL: webhook ?? "" },
71
74
  });
72
75
 
73
76
  child.unref();
@@ -93,6 +96,7 @@ export async function startDaemon(port = DEFAULT_PORT): Promise<void> {
93
96
  console.log(` PID: ${child.pid}`);
94
97
  console.log(` Port: ${port}`);
95
98
  console.log(` URL: http://localhost:${port}/mcp`);
99
+ if (webhook) console.log(` Webhook: ${webhook}`);
96
100
  console.log(` Logs: ${LOG_FILE}`);
97
101
  }
98
102
 
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;
@@ -2,7 +2,11 @@
2
2
  * HTTP MCP server — runs as a daemon process.
3
3
  * Spawned by `weixin-mcp start`, listens on a given port.
4
4
  *
5
- * Clients connect via: http://localhost:<port>/mcp
5
+ * Features:
6
+ * - MCP endpoint at /mcp (StreamableHTTP)
7
+ * - Health check at /health
8
+ * - Webhook push: --webhook <url> to receive new messages via POST
9
+ * - Auto-poll: when webhook is set, background polling forwards messages
6
10
  */
7
11
 
8
12
  import express from "express";
@@ -14,7 +18,6 @@ import {
14
18
  } from "@modelcontextprotocol/sdk/types.js";
15
19
  import { randomUUID } from "node:crypto";
16
20
 
17
- // Reuse the same tool definitions and handlers from the main server
18
21
  import {
19
22
  DEFAULT_BASE_URL,
20
23
  getUpdates,
@@ -26,10 +29,16 @@ import {
26
29
  WeixinNetworkError,
27
30
  } from "./api.js";
28
31
  import { ACCOUNTS_DIR } from "./paths.js";
32
+ import { updateContactsFromMsgs, loadContacts, type ContactBook } from "./contacts.js";
29
33
  import fs from "node:fs";
30
34
  import path from "node:path";
31
35
 
32
- const port = Number(process.env.WEIXIN_MCP_PORT ?? process.argv[2] ?? 3001);
36
+ // Parse CLI args
37
+ const args = process.argv.slice(2);
38
+ const portIdx = args.indexOf("--port");
39
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : Number(process.env.WEIXIN_MCP_PORT ?? 3001);
40
+ const webhookIdx = args.indexOf("--webhook");
41
+ const webhookUrl = webhookIdx >= 0 ? args[webhookIdx + 1] : process.env.WEIXIN_WEBHOOK_URL;
33
42
 
34
43
  // ── Account loader ─────────────────────────────────────────────────────────
35
44
 
@@ -56,11 +65,19 @@ function fmtErr(e: unknown): string {
56
65
  return String(e);
57
66
  }
58
67
 
68
+ function resolveUserId(input: string, contacts: ContactBook): string {
69
+ if (!input || input.includes("@")) return input;
70
+ const ids = Object.keys(contacts);
71
+ const matches = ids.filter((id) => id.startsWith(input) || id.includes(input));
72
+ if (matches.length === 1) return matches[0];
73
+ return input;
74
+ }
75
+
59
76
  // ── MCP server factory ─────────────────────────────────────────────────────
60
77
 
61
78
  function createMCPServer() {
62
79
  const server = new Server(
63
- { name: "weixin-mcp", version: "1.2.2" },
80
+ { name: "weixin-mcp", version: "1.5.0" },
64
81
  { capabilities: { tools: {} } },
65
82
  );
66
83
 
@@ -72,7 +89,7 @@ function createMCPServer() {
72
89
  inputSchema: {
73
90
  type: "object",
74
91
  properties: {
75
- to: { type: "string" },
92
+ to: { type: "string", description: "Recipient (full ID or short prefix)" },
76
93
  text: { type: "string" },
77
94
  context_token: { type: "string" },
78
95
  },
@@ -87,6 +104,11 @@ function createMCPServer() {
87
104
  properties: { reset_cursor: { type: "boolean" } },
88
105
  },
89
106
  },
107
+ {
108
+ name: "weixin_contacts",
109
+ description: "List users who have messaged the bot.",
110
+ inputSchema: { type: "object", properties: {} },
111
+ },
90
112
  {
91
113
  name: "weixin_get_config",
92
114
  description: "Get user config (typing ticket, etc.).",
@@ -109,13 +131,17 @@ function createMCPServer() {
109
131
  let result: unknown;
110
132
  if (name === "weixin_send") {
111
133
  const a = (args ?? {}) as { to?: string; text?: string; context_token?: string };
112
- result = await sendTextMessage(assertStr(a.to, "to"), assertStr(a.text, "text"), token!, baseUrl, a.context_token);
134
+ const resolvedTo = resolveUserId(assertStr(a.to, "to"), loadContacts());
135
+ result = await sendTextMessage(resolvedTo, assertStr(a.text, "text"), token!, baseUrl, a.context_token);
113
136
  } else if (name === "weixin_poll") {
114
137
  const { reset_cursor } = (args ?? {}) as { reset_cursor?: boolean };
115
138
  const cursor = reset_cursor ? "" : loadCursor(accountId);
116
139
  const resp = await getUpdates(token!, baseUrl, cursor);
117
140
  if (resp.get_updates_buf) saveCursor(accountId, resp.get_updates_buf);
141
+ if (resp.msgs && resp.msgs.length > 0) updateContactsFromMsgs(resp.msgs as unknown[]);
118
142
  result = resp;
143
+ } else if (name === "weixin_contacts") {
144
+ result = Object.values(loadContacts());
119
145
  } else if (name === "weixin_get_config") {
120
146
  const a = (args ?? {}) as { user_id?: string; context_token?: string };
121
147
  result = await getConfig(assertStr(a.user_id, "user_id"), token!, baseUrl, a.context_token);
@@ -131,21 +157,61 @@ function createMCPServer() {
131
157
  return server;
132
158
  }
133
159
 
160
+ // ── Webhook push ───────────────────────────────────────────────────────────
161
+
162
+ async function pushToWebhook(msgs: unknown[]) {
163
+ if (!webhookUrl || msgs.length === 0) return;
164
+ try {
165
+ await fetch(webhookUrl, {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify({ event: "weixin_messages", messages: msgs, timestamp: new Date().toISOString() }),
169
+ });
170
+ } catch (err) {
171
+ console.error("[weixin-mcp] webhook push failed:", fmtErr(err));
172
+ }
173
+ }
174
+
175
+ // ── Background poller (when webhook is set) ────────────────────────────────
176
+
177
+ async function startBackgroundPoller() {
178
+ if (!webhookUrl) return;
179
+ console.log(`[weixin-mcp] Webhook enabled: ${webhookUrl}`);
180
+ console.log("[weixin-mcp] Starting background poller...");
181
+
182
+ while (true) {
183
+ try {
184
+ const { token, baseUrl = DEFAULT_BASE_URL, accountId } = loadAccount();
185
+ const cursor = loadCursor(accountId);
186
+ const resp = await getUpdates(token!, baseUrl, cursor);
187
+
188
+ if (resp.get_updates_buf) saveCursor(accountId, resp.get_updates_buf);
189
+
190
+ if (resp.msgs && resp.msgs.length > 0) {
191
+ updateContactsFromMsgs(resp.msgs as unknown[]);
192
+ await pushToWebhook(resp.msgs);
193
+ console.log(`[weixin-mcp] Pushed ${resp.msgs.length} message(s) to webhook`);
194
+ }
195
+ } catch (err) {
196
+ console.error("[weixin-mcp] poll error:", fmtErr(err));
197
+ await new Promise((r) => setTimeout(r, 5000)); // backoff on error
198
+ }
199
+ // getUpdates is long-poll (~30s timeout), so no extra delay needed
200
+ }
201
+ }
202
+
134
203
  // ── Express HTTP server ────────────────────────────────────────────────────
135
204
 
136
205
  const app = express();
137
206
  app.use(express.json());
138
207
 
139
- // Session store for stateful transports
140
208
  const sessions = new Map<string, StreamableHTTPServerTransport>();
141
209
 
142
210
  app.post("/mcp", async (req, res) => {
143
- // Check if this is an existing session
144
211
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
145
212
  let transport = sessionId ? sessions.get(sessionId) : undefined;
146
213
 
147
214
  if (!transport) {
148
- // New session
149
215
  const newSessionId = randomUUID();
150
216
  transport = new StreamableHTTPServerTransport({
151
217
  sessionIdGenerator: () => newSessionId,
@@ -174,10 +240,11 @@ app.delete("/mcp", async (req, res) => {
174
240
  });
175
241
 
176
242
  app.get("/health", (_req, res) => {
177
- res.json({ status: "ok", port, sessions: sessions.size });
243
+ res.json({ status: "ok", port, sessions: sessions.size, webhook: webhookUrl ?? null });
178
244
  });
179
245
 
180
246
  app.listen(port, () => {
181
- console.log(`[weixin-mcp] HTTP MCP server listening on port ${port}`);
182
- console.log(`[weixin-mcp] MCP endpoint: http://localhost:${port}/mcp`);
247
+ console.log(`[weixin-mcp] HTTP MCP server on port ${port}`);
248
+ console.log(`[weixin-mcp] MCP: http://localhost:${port}/mcp`);
249
+ if (webhookUrl) startBackgroundPoller();
183
250
  });