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 +2 -2
- package/README.md +2 -2
- package/dist/api.d.ts +20 -0
- package/dist/api.js +103 -0
- package/dist/cdn.d.ts +24 -0
- package/dist/cdn.js +130 -0
- package/dist/cli.js +4 -2
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +8 -3
- package/dist/index.js +60 -1
- package/dist/server-http.d.ts +5 -1
- package/dist/server-http.js +81 -12
- package/package.json +1 -1
- package/src/api.ts +153 -0
- package/src/cdn.ts +185 -0
- package/src/cli.ts +4 -2
- package/src/daemon.ts +7 -3
- package/src/index.ts +71 -0
- package/src/server-http.ts +79 -12
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
|
|
46
|
-
# Resolved "
|
|
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
|
|
48
|
-
# Resolved "
|
|
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]
|
|
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
|
|
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");
|
package/dist/server-http.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {};
|
package/dist/server-http.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
155
|
-
console.log(`[weixin-mcp] 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
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]
|
|
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
|
|
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;
|
package/src/server-http.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
182
|
-
console.log(`[weixin-mcp] 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
|
});
|