@paean-ai/wechat-mcp 0.1.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/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/cli.js +1341 -0
- package/dist/daemon/server.js +565 -0
- package/dist/index.d.ts +195 -0
- package/dist/index.js +443 -0
- package/package.json +66 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/daemon/server.ts
|
|
4
|
+
import http from "http";
|
|
5
|
+
|
|
6
|
+
// src/constants.ts
|
|
7
|
+
import path from "path";
|
|
8
|
+
import os from "os";
|
|
9
|
+
var CHANNEL_VERSION = "0.1.0";
|
|
10
|
+
var MSG_TYPE_USER = 1;
|
|
11
|
+
var MSG_TYPE_BOT = 2;
|
|
12
|
+
var MSG_STATE_FINISH = 2;
|
|
13
|
+
var MSG_ITEM_TEXT = 1;
|
|
14
|
+
var MSG_ITEM_VOICE = 3;
|
|
15
|
+
var LONG_POLL_TIMEOUT_MS = 35e3;
|
|
16
|
+
var SEND_TIMEOUT_MS = 15e3;
|
|
17
|
+
var MAX_CONSECUTIVE_FAILURES = 3;
|
|
18
|
+
var BACKOFF_DELAY_MS = 3e4;
|
|
19
|
+
var RETRY_DELAY_MS = 2e3;
|
|
20
|
+
var DAEMON_LONG_POLL_MS = 3e4;
|
|
21
|
+
var DAEMON_HOST = "127.0.0.1";
|
|
22
|
+
var CONFIG_DIR = path.join(os.homedir(), ".wechat-mcp");
|
|
23
|
+
var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
24
|
+
var SYNC_BUF_FILE = path.join(CONFIG_DIR, "sync_buf.txt");
|
|
25
|
+
var CONTACTS_FILE = path.join(CONFIG_DIR, "contacts.json");
|
|
26
|
+
var DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
27
|
+
var DAEMON_INFO_FILE = path.join(CONFIG_DIR, "daemon.json");
|
|
28
|
+
var MAX_WECHAT_MSG_LENGTH = 2048;
|
|
29
|
+
var MESSAGE_QUEUE_MAX_PER_AGENT = 200;
|
|
30
|
+
|
|
31
|
+
// src/credentials.ts
|
|
32
|
+
import fs from "fs";
|
|
33
|
+
function ensureDir() {
|
|
34
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
function loadCredentials() {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) return null;
|
|
39
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function loadSyncBuf() {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(SYNC_BUF_FILE)) {
|
|
47
|
+
return fs.readFileSync(SYNC_BUF_FILE, "utf-8");
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
function saveSyncBuf(buf) {
|
|
54
|
+
try {
|
|
55
|
+
ensureDir();
|
|
56
|
+
fs.writeFileSync(SYNC_BUF_FILE, buf, "utf-8");
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function loadContacts() {
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(CONTACTS_FILE)) return [];
|
|
63
|
+
return JSON.parse(fs.readFileSync(CONTACTS_FILE, "utf-8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function saveContact(userId, contextToken) {
|
|
69
|
+
const contacts = loadContacts();
|
|
70
|
+
const displayName = userId.split("@")[0] || userId;
|
|
71
|
+
const idx = contacts.findIndex((c) => c.userId === userId);
|
|
72
|
+
const entry = {
|
|
73
|
+
userId,
|
|
74
|
+
contextToken,
|
|
75
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
76
|
+
displayName
|
|
77
|
+
};
|
|
78
|
+
if (idx >= 0) contacts[idx] = entry;
|
|
79
|
+
else contacts.push(entry);
|
|
80
|
+
try {
|
|
81
|
+
ensureDir();
|
|
82
|
+
fs.writeFileSync(CONTACTS_FILE, JSON.stringify(contacts, null, 2), "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function getContactToken(userId) {
|
|
87
|
+
const contacts = loadContacts();
|
|
88
|
+
const match = contacts.find(
|
|
89
|
+
(c) => c.userId === userId || c.displayName === userId
|
|
90
|
+
);
|
|
91
|
+
return match?.contextToken ?? null;
|
|
92
|
+
}
|
|
93
|
+
function saveDaemonInfo(info) {
|
|
94
|
+
ensureDir();
|
|
95
|
+
fs.writeFileSync(DAEMON_INFO_FILE, JSON.stringify(info, null, 2), "utf-8");
|
|
96
|
+
}
|
|
97
|
+
function removeDaemonInfo() {
|
|
98
|
+
for (const file of [DAEMON_INFO_FILE, DAEMON_PID_FILE]) {
|
|
99
|
+
try {
|
|
100
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function saveDaemonPid(pid) {
|
|
106
|
+
ensureDir();
|
|
107
|
+
fs.writeFileSync(DAEMON_PID_FILE, String(pid), "utf-8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/wechat-api.ts
|
|
111
|
+
import crypto from "crypto";
|
|
112
|
+
function randomWechatUin() {
|
|
113
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
114
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
115
|
+
}
|
|
116
|
+
function buildHeaders(token, body) {
|
|
117
|
+
const headers = {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
AuthorizationType: "ilink_bot_token",
|
|
120
|
+
"X-WECHAT-UIN": randomWechatUin()
|
|
121
|
+
};
|
|
122
|
+
if (body) {
|
|
123
|
+
headers["Content-Length"] = String(Buffer.byteLength(body, "utf-8"));
|
|
124
|
+
}
|
|
125
|
+
if (token?.trim()) {
|
|
126
|
+
headers.Authorization = `Bearer ${token.trim()}`;
|
|
127
|
+
}
|
|
128
|
+
return headers;
|
|
129
|
+
}
|
|
130
|
+
function normalizeBaseUrl(baseUrl) {
|
|
131
|
+
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
132
|
+
}
|
|
133
|
+
async function apiFetch(baseUrl, endpoint, body, token, timeoutMs = 15e3) {
|
|
134
|
+
const url = new URL(endpoint, normalizeBaseUrl(baseUrl)).toString();
|
|
135
|
+
const headers = buildHeaders(token, body);
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(url, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers,
|
|
142
|
+
body,
|
|
143
|
+
signal: controller.signal
|
|
144
|
+
});
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
|
|
148
|
+
return text;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function getUpdates(baseUrl, token, getUpdatesBuf) {
|
|
155
|
+
try {
|
|
156
|
+
const raw = await apiFetch(
|
|
157
|
+
baseUrl,
|
|
158
|
+
"ilink/bot/getupdates",
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
get_updates_buf: getUpdatesBuf,
|
|
161
|
+
base_info: { channel_version: CHANNEL_VERSION }
|
|
162
|
+
}),
|
|
163
|
+
token,
|
|
164
|
+
LONG_POLL_TIMEOUT_MS
|
|
165
|
+
);
|
|
166
|
+
return JSON.parse(raw);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
169
|
+
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function generateClientId() {
|
|
175
|
+
return `wechat-mcp:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
176
|
+
}
|
|
177
|
+
async function sendTextMessage(baseUrl, token, to, text, contextToken) {
|
|
178
|
+
const clientId = generateClientId();
|
|
179
|
+
await apiFetch(
|
|
180
|
+
baseUrl,
|
|
181
|
+
"ilink/bot/sendmessage",
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
msg: {
|
|
184
|
+
from_user_id: "",
|
|
185
|
+
to_user_id: to,
|
|
186
|
+
client_id: clientId,
|
|
187
|
+
message_type: MSG_TYPE_BOT,
|
|
188
|
+
message_state: MSG_STATE_FINISH,
|
|
189
|
+
item_list: [{ type: MSG_ITEM_TEXT, text_item: { text } }],
|
|
190
|
+
context_token: contextToken
|
|
191
|
+
},
|
|
192
|
+
base_info: { channel_version: CHANNEL_VERSION }
|
|
193
|
+
}),
|
|
194
|
+
token,
|
|
195
|
+
SEND_TIMEOUT_MS
|
|
196
|
+
);
|
|
197
|
+
return clientId;
|
|
198
|
+
}
|
|
199
|
+
function extractTextFromMessage(msg) {
|
|
200
|
+
if (!msg.item_list?.length) return "";
|
|
201
|
+
for (const item of msg.item_list) {
|
|
202
|
+
if (item.type === MSG_ITEM_TEXT && item.text_item?.text) {
|
|
203
|
+
const text = item.text_item.text;
|
|
204
|
+
const ref = item.ref_msg;
|
|
205
|
+
if (!ref) return text;
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (ref.title) parts.push(ref.title);
|
|
208
|
+
if (!parts.length) return text;
|
|
209
|
+
return `[Quote: ${parts.join(" | ")}]
|
|
210
|
+
${text}`;
|
|
211
|
+
}
|
|
212
|
+
if (item.type === MSG_ITEM_VOICE && item.voice_item?.text) {
|
|
213
|
+
return item.voice_item.text;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/daemon/store.ts
|
|
220
|
+
var DaemonStore = class {
|
|
221
|
+
agents = /* @__PURE__ */ new Map();
|
|
222
|
+
defaultAgentId = null;
|
|
223
|
+
startedAt = Date.now();
|
|
224
|
+
registerAgent(agentId, name) {
|
|
225
|
+
const existing = this.agents.get(agentId);
|
|
226
|
+
if (existing) return existing.registration;
|
|
227
|
+
const registration = {
|
|
228
|
+
agentId,
|
|
229
|
+
name: name || agentId,
|
|
230
|
+
registeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
231
|
+
};
|
|
232
|
+
this.agents.set(agentId, {
|
|
233
|
+
registration,
|
|
234
|
+
messages: [],
|
|
235
|
+
waiters: []
|
|
236
|
+
});
|
|
237
|
+
if (!this.defaultAgentId) {
|
|
238
|
+
this.defaultAgentId = agentId;
|
|
239
|
+
}
|
|
240
|
+
return registration;
|
|
241
|
+
}
|
|
242
|
+
unregisterAgent(agentId) {
|
|
243
|
+
const state = this.agents.get(agentId);
|
|
244
|
+
if (!state) return false;
|
|
245
|
+
for (const w of state.waiters) {
|
|
246
|
+
clearTimeout(w.timer);
|
|
247
|
+
w.resolve([]);
|
|
248
|
+
}
|
|
249
|
+
this.agents.delete(agentId);
|
|
250
|
+
if (this.defaultAgentId === agentId) {
|
|
251
|
+
const first = this.agents.keys().next();
|
|
252
|
+
this.defaultAgentId = first.done ? null : first.value;
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
getAgents() {
|
|
257
|
+
return Array.from(this.agents.values()).map((s) => s.registration);
|
|
258
|
+
}
|
|
259
|
+
getDefaultAgentId() {
|
|
260
|
+
return this.defaultAgentId;
|
|
261
|
+
}
|
|
262
|
+
hasAgent(agentId) {
|
|
263
|
+
return this.agents.has(agentId);
|
|
264
|
+
}
|
|
265
|
+
getUptime() {
|
|
266
|
+
return Date.now() - this.startedAt;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Push a message to a specific agent's queue.
|
|
270
|
+
* If an agent has a waiting long-poll, resolve it immediately.
|
|
271
|
+
*/
|
|
272
|
+
pushMessage(agentId, message) {
|
|
273
|
+
const state = this.agents.get(agentId);
|
|
274
|
+
if (!state) return false;
|
|
275
|
+
if (state.waiters.length > 0) {
|
|
276
|
+
const waiter = state.waiters.shift();
|
|
277
|
+
clearTimeout(waiter.timer);
|
|
278
|
+
waiter.resolve([message]);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
state.messages.push(message);
|
|
282
|
+
if (state.messages.length > MESSAGE_QUEUE_MAX_PER_AGENT) {
|
|
283
|
+
state.messages.shift();
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Drain all queued messages for an agent, or wait up to timeoutMs
|
|
289
|
+
* for new ones to arrive.
|
|
290
|
+
*/
|
|
291
|
+
async pollMessages(agentId, timeoutMs) {
|
|
292
|
+
const state = this.agents.get(agentId);
|
|
293
|
+
if (!state) return [];
|
|
294
|
+
if (state.messages.length > 0) {
|
|
295
|
+
const msgs = state.messages.splice(0);
|
|
296
|
+
return msgs;
|
|
297
|
+
}
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
const idx = state.waiters.findIndex((w) => w.resolve === resolve);
|
|
301
|
+
if (idx >= 0) state.waiters.splice(idx, 1);
|
|
302
|
+
resolve([]);
|
|
303
|
+
}, timeoutMs);
|
|
304
|
+
state.waiters.push({ resolve, timer });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/daemon/router.ts
|
|
310
|
+
import crypto2 from "crypto";
|
|
311
|
+
function parseMention(text) {
|
|
312
|
+
const trimmed = text.trimStart();
|
|
313
|
+
const match = trimmed.match(/^@(\S+)\s*([\s\S]*)$/);
|
|
314
|
+
if (!match) {
|
|
315
|
+
return { agent: null, rest: text };
|
|
316
|
+
}
|
|
317
|
+
return { agent: match[1].toLowerCase(), rest: match[2] || "" };
|
|
318
|
+
}
|
|
319
|
+
function routeMessage(store2, senderId, text, contextToken) {
|
|
320
|
+
const { agent, rest } = parseMention(text);
|
|
321
|
+
let targetAgent = null;
|
|
322
|
+
let strippedText = text;
|
|
323
|
+
if (agent && store2.hasAgent(agent)) {
|
|
324
|
+
targetAgent = agent;
|
|
325
|
+
strippedText = rest || text;
|
|
326
|
+
} else {
|
|
327
|
+
targetAgent = store2.getDefaultAgentId();
|
|
328
|
+
strippedText = text;
|
|
329
|
+
}
|
|
330
|
+
if (!targetAgent) return null;
|
|
331
|
+
const message = {
|
|
332
|
+
id: crypto2.randomBytes(8).toString("hex"),
|
|
333
|
+
senderId,
|
|
334
|
+
senderName: senderId.split("@")[0] || senderId,
|
|
335
|
+
text: strippedText,
|
|
336
|
+
rawText: text,
|
|
337
|
+
contextToken,
|
|
338
|
+
targetAgent,
|
|
339
|
+
timestamp: Date.now()
|
|
340
|
+
};
|
|
341
|
+
store2.pushMessage(targetAgent, message);
|
|
342
|
+
return message;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/daemon/server.ts
|
|
346
|
+
var store = new DaemonStore();
|
|
347
|
+
var activeAccount = null;
|
|
348
|
+
var wechatConnected = false;
|
|
349
|
+
function log(msg) {
|
|
350
|
+
process.stderr.write(`[wechat-mcp:daemon] ${msg}
|
|
351
|
+
`);
|
|
352
|
+
}
|
|
353
|
+
async function readBody(req) {
|
|
354
|
+
const chunks = [];
|
|
355
|
+
for await (const chunk of req) {
|
|
356
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
357
|
+
}
|
|
358
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
359
|
+
}
|
|
360
|
+
function json(res, status, data) {
|
|
361
|
+
const body = JSON.stringify(data);
|
|
362
|
+
res.writeHead(status, {
|
|
363
|
+
"Content-Type": "application/json",
|
|
364
|
+
"Content-Length": Buffer.byteLength(body)
|
|
365
|
+
});
|
|
366
|
+
res.end(body);
|
|
367
|
+
}
|
|
368
|
+
async function handleRequest(req, res) {
|
|
369
|
+
const url = new URL(req.url || "/", `http://${DAEMON_HOST}`);
|
|
370
|
+
const method = req.method?.toUpperCase() || "GET";
|
|
371
|
+
const path2 = url.pathname;
|
|
372
|
+
try {
|
|
373
|
+
if (method === "GET" && path2 === "/health") {
|
|
374
|
+
json(res, 200, { ok: true, pid: process.pid });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (method === "GET" && path2 === "/status") {
|
|
378
|
+
const status = {
|
|
379
|
+
running: true,
|
|
380
|
+
wechatConnected,
|
|
381
|
+
accountId: activeAccount?.accountId,
|
|
382
|
+
agents: store.getAgents(),
|
|
383
|
+
uptime: store.getUptime()
|
|
384
|
+
};
|
|
385
|
+
json(res, 200, status);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (method === "POST" && path2 === "/agents") {
|
|
389
|
+
const body = JSON.parse(await readBody(req));
|
|
390
|
+
if (!body.agentId) {
|
|
391
|
+
json(res, 400, { error: "agentId required" });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const reg = store.registerAgent(body.agentId, body.name);
|
|
395
|
+
log(`Agent registered: ${reg.agentId} (${reg.name})`);
|
|
396
|
+
json(res, 200, reg);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (method === "DELETE" && path2.startsWith("/agents/")) {
|
|
400
|
+
const agentId = decodeURIComponent(path2.slice("/agents/".length));
|
|
401
|
+
const removed = store.unregisterAgent(agentId);
|
|
402
|
+
if (removed) log(`Agent unregistered: ${agentId}`);
|
|
403
|
+
json(res, 200, { removed });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (method === "GET" && path2.startsWith("/messages/")) {
|
|
407
|
+
const agentId = decodeURIComponent(path2.slice("/messages/".length));
|
|
408
|
+
if (!store.hasAgent(agentId)) {
|
|
409
|
+
json(res, 404, { error: "agent not registered" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const messages = await store.pollMessages(agentId, DAEMON_LONG_POLL_MS);
|
|
413
|
+
const resp = { messages };
|
|
414
|
+
json(res, 200, resp);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (method === "POST" && path2 === "/send") {
|
|
418
|
+
const body = JSON.parse(await readBody(req));
|
|
419
|
+
if (!body.to || !body.text) {
|
|
420
|
+
json(res, 400, { success: false, error: "to and text required" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!activeAccount) {
|
|
424
|
+
json(res, 503, { success: false, error: "WeChat not connected" });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const contextToken = getContactToken(body.to);
|
|
428
|
+
if (!contextToken) {
|
|
429
|
+
json(res, 404, {
|
|
430
|
+
success: false,
|
|
431
|
+
error: `No context token for "${body.to}". User must message the bot first.`
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
for (let i = 0; i < body.text.length; i += MAX_WECHAT_MSG_LENGTH) {
|
|
437
|
+
await sendTextMessage(
|
|
438
|
+
activeAccount.baseUrl,
|
|
439
|
+
activeAccount.token,
|
|
440
|
+
body.to,
|
|
441
|
+
body.text.slice(i, i + MAX_WECHAT_MSG_LENGTH),
|
|
442
|
+
contextToken
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
json(res, 200, { success: true });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
json(res, 500, {
|
|
448
|
+
success: false,
|
|
449
|
+
error: err instanceof Error ? err.message : String(err)
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (method === "GET" && path2 === "/contacts") {
|
|
455
|
+
json(res, 200, { contacts: loadContacts() });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
json(res, 404, { error: "not found" });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log(`HTTP error: ${err instanceof Error ? err.message : String(err)}`);
|
|
461
|
+
json(res, 500, { error: "internal error" });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function startPolling(account) {
|
|
465
|
+
let buf = loadSyncBuf();
|
|
466
|
+
let failures = 0;
|
|
467
|
+
if (buf) log(`Restored sync state (${buf.length} bytes)`);
|
|
468
|
+
log("Listening for WeChat messages...");
|
|
469
|
+
wechatConnected = true;
|
|
470
|
+
while (true) {
|
|
471
|
+
try {
|
|
472
|
+
const resp = await getUpdates(account.baseUrl, account.token, buf);
|
|
473
|
+
const isError = resp.ret !== void 0 && resp.ret !== 0 || resp.errcode !== void 0 && resp.errcode !== 0;
|
|
474
|
+
if (isError) {
|
|
475
|
+
failures++;
|
|
476
|
+
log(`getUpdates error: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""}`);
|
|
477
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
478
|
+
wechatConnected = false;
|
|
479
|
+
failures = 0;
|
|
480
|
+
await new Promise((r) => setTimeout(r, BACKOFF_DELAY_MS));
|
|
481
|
+
} else {
|
|
482
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
failures = 0;
|
|
487
|
+
wechatConnected = true;
|
|
488
|
+
if (resp.get_updates_buf) {
|
|
489
|
+
buf = resp.get_updates_buf;
|
|
490
|
+
saveSyncBuf(buf);
|
|
491
|
+
}
|
|
492
|
+
for (const msg of resp.msgs ?? []) {
|
|
493
|
+
if (msg.message_type !== MSG_TYPE_USER) continue;
|
|
494
|
+
const text = extractTextFromMessage(msg);
|
|
495
|
+
if (!text) continue;
|
|
496
|
+
const senderId = msg.from_user_id ?? "unknown";
|
|
497
|
+
const contextToken = msg.context_token ?? "";
|
|
498
|
+
if (contextToken) {
|
|
499
|
+
saveContact(senderId, contextToken);
|
|
500
|
+
}
|
|
501
|
+
const routed = routeMessage(store, senderId, text, contextToken);
|
|
502
|
+
if (routed) {
|
|
503
|
+
log(`Message routed: from=${senderId} agent=${routed.targetAgent} text="${text.slice(0, 50)}..."`);
|
|
504
|
+
} else {
|
|
505
|
+
log(`Message dropped (no agents): from=${senderId} text="${text.slice(0, 50)}..."`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
failures++;
|
|
510
|
+
log(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
|
|
511
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
512
|
+
wechatConnected = false;
|
|
513
|
+
failures = 0;
|
|
514
|
+
await new Promise((r) => setTimeout(r, BACKOFF_DELAY_MS));
|
|
515
|
+
} else {
|
|
516
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function startDaemon() {
|
|
522
|
+
const account = loadCredentials();
|
|
523
|
+
if (!account) {
|
|
524
|
+
log("No credentials found. Run `wechat-mcp setup` first.");
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
activeAccount = account;
|
|
528
|
+
const server = http.createServer((req, res) => {
|
|
529
|
+
handleRequest(req, res).catch(() => {
|
|
530
|
+
if (!res.headersSent) json(res, 500, { error: "internal error" });
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
await new Promise((resolve, reject) => {
|
|
534
|
+
server.listen(0, DAEMON_HOST, () => resolve());
|
|
535
|
+
server.on("error", reject);
|
|
536
|
+
});
|
|
537
|
+
const addr = server.address();
|
|
538
|
+
if (!addr || typeof addr === "string") {
|
|
539
|
+
log("Failed to get server address");
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
const port = addr.port;
|
|
543
|
+
saveDaemonPid(process.pid);
|
|
544
|
+
saveDaemonInfo({ pid: process.pid, port, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
545
|
+
log(`Daemon running on ${DAEMON_HOST}:${port} (PID ${process.pid})`);
|
|
546
|
+
log(`Account: ${account.accountId}`);
|
|
547
|
+
const cleanup = () => {
|
|
548
|
+
log("Shutting down...");
|
|
549
|
+
removeDaemonInfo();
|
|
550
|
+
server.close();
|
|
551
|
+
process.exit(0);
|
|
552
|
+
};
|
|
553
|
+
process.on("SIGINT", cleanup);
|
|
554
|
+
process.on("SIGTERM", cleanup);
|
|
555
|
+
startPolling(account).catch((err) => {
|
|
556
|
+
log(`Fatal polling error: ${err instanceof Error ? err.message : String(err)}`);
|
|
557
|
+
cleanup();
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (process.argv[1] && (process.argv[1].endsWith("daemon/server.js") || process.argv[1].endsWith("daemon/server.ts"))) {
|
|
561
|
+
startDaemon();
|
|
562
|
+
}
|
|
563
|
+
export {
|
|
564
|
+
startDaemon
|
|
565
|
+
};
|