cc-wechat 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,460 +1,463 @@
1
- #!/usr/bin/env node
2
- /**
3
- * cc-wechat MCP Server 主入口
4
- * Claude Code Channel 插件 — 微信消息桥接
5
- */
6
-
7
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
- import fs from 'node:fs';
11
-
12
- import { getActiveAccount, saveAccount, loadSyncBuf, saveSyncBuf } from './store.js';
13
- import { getUpdates, sendMessage, sendTyping, getConfig } from './ilink-api.js';
14
- import { loginBrowser } from './auth.js';
15
- import { uploadMedia, downloadMedia } from './cdn.js';
16
- import { stripMarkdown, chunkText } from './text-utils.js';
17
- import type { WeixinMessage, AccountData } from './types.js';
18
- import { MessageItemType } from './types.js';
19
-
20
- // ─── 状态变量 ─────────────────────────────────────────
21
-
22
- let pollingActive = false;
23
- let pollingAbort: AbortController | null = null;
24
- const typingTicketCache = new Map<string, string>();
25
-
26
- // ─── Session 过期处理常量 ─────────────────────────────
27
-
28
- const SESSION_EXPIRED_ERRCODE = -14;
29
- const MAX_SESSION_RETRIES = 3;
30
- const MAX_CONSECUTIVE_FAILURES = 5;
31
- const INITIAL_RETRY_DELAY_MS = 2_000;
32
- const MAX_RETRY_DELAY_MS = 30_000;
33
- const SESSION_PAUSE_MS = 5 * 60_000;
34
-
35
- // ─── 辅助函数 ─────────────────────────────────────────
36
-
37
- /** 可中断的 sleep */
38
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
39
- return new Promise((resolve, reject) => {
40
- const t = setTimeout(resolve, ms);
41
- signal?.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')); }, { once: true });
42
- });
43
- }
44
-
45
- /** 从消息提取可读文本(异步,支持媒体下载) */
46
- async function extractText(msg: WeixinMessage): Promise<string> {
47
- const parts: string[] = [];
48
- for (const item of msg.item_list ?? []) {
49
- const t = item.type ?? 0;
50
-
51
- if (t === MessageItemType.TEXT) {
52
- if (item.text_item?.text) {
53
- // 提取引用回复内容
54
- if (item.ref_msg) {
55
- const refTitle = item.ref_msg.title ?? '';
56
- const refText = item.ref_msg.message_item?.text_item?.text ?? '';
57
- const refContent = refTitle || refText;
58
- if (refContent) {
59
- parts.push(`[引用: ${refContent}]`);
60
- }
61
- }
62
- parts.push(item.text_item.text);
63
- }
64
- } else if (t === MessageItemType.IMAGE) {
65
- let desc = '[图片]';
66
- if (item.image_item?.media?.encrypt_query_param && item.image_item?.media?.aes_key) {
67
- try {
68
- const filePath = await downloadMedia({
69
- encryptQueryParam: item.image_item.media.encrypt_query_param,
70
- aesKeyBase64: item.image_item.media.aes_key,
71
- });
72
- desc += `\n[附件: ${filePath}]`;
73
- } catch {
74
- // 下载失败不阻塞消息处理
75
- }
76
- }
77
- parts.push(desc);
78
- } else if (t === MessageItemType.VOICE) {
79
- parts.push(`[语音] ${item.voice_item?.text ?? ''}`);
80
- } else if (t === MessageItemType.FILE) {
81
- let desc = `[文件: ${item.file_item?.file_name ?? 'unknown'}]`;
82
- if (item.file_item?.media?.encrypt_query_param && item.file_item?.media?.aes_key) {
83
- try {
84
- const filePath = await downloadMedia({
85
- encryptQueryParam: item.file_item.media.encrypt_query_param,
86
- aesKeyBase64: item.file_item.media.aes_key,
87
- fileName: item.file_item.file_name,
88
- });
89
- desc += `\n[附件: ${filePath}]`;
90
- } catch {
91
- // 下载失败不阻塞消息处理
92
- }
93
- }
94
- parts.push(desc);
95
- } else if (t === MessageItemType.VIDEO) {
96
- parts.push('[视频]');
97
- }
98
- }
99
- return parts.join('\n') || '[空消息]';
100
- }
101
-
102
- // ─── MCP Server 创建 ─────────────────────────────────
103
-
104
- const server = new Server(
105
- { name: 'wechat-channel', version: '0.1.0' },
106
- {
107
- capabilities: {
108
- experimental: { 'claude/channel': {} },
109
- tools: {},
110
- },
111
- instructions: `Messages arrive as <channel source="wechat-channel" user_id="..." context_token="..." message_id="...">.
112
- Reply using the reply tool. Pass user_id and context_token from the channel tag.
113
- For media: set media to an absolute local file path to send image/video/file.
114
- For quote reply: set reply_to_message_id to the message_id from the channel tag to send a quoted reply.
115
- IMPORTANT: Always use the reply tool to respond to WeChat messages. Do not just output text.`,
116
- },
117
- );
118
-
119
- // ─── Tools — ListToolsRequestSchema ──────────────────
120
-
121
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
122
- tools: [
123
- {
124
- name: 'login',
125
- description: '扫码登录微信。首次使用或 session 过期后运行。',
126
- inputSchema: {
127
- type: 'object' as const,
128
- properties: {},
129
- },
130
- },
131
- {
132
- name: 'reply',
133
- description: '回复微信消息',
134
- inputSchema: {
135
- type: 'object' as const,
136
- properties: {
137
- user_id: { type: 'string', description: '微信用户 ID(来自消息 meta 的 user_id)' },
138
- context_token: { type: 'string', description: '会话上下文令牌(来自消息 meta 的 context_token)' },
139
- content: { type: 'string', description: '回复文本内容' },
140
- media: { type: 'string', description: '可选:本地文件绝对路径,发送图片/视频/文件' },
141
- reply_to_message_id: { type: 'string', description: '可选:引用回复的原消息 ID(来自 meta 的 message_id)' },
142
- },
143
- required: ['user_id', 'context_token', 'content'],
144
- },
145
- },
146
- ],
147
- }));
148
-
149
- // ─── Tools — CallToolRequestSchema ───────────────────
150
-
151
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
152
- const { name, arguments: args } = request.params;
153
-
154
- // ── login tool ──
155
- if (name === 'login') {
156
- try {
157
- const result = await loginBrowser();
158
- saveAccount({
159
- token: result.token,
160
- baseUrl: result.baseUrl ?? '',
161
- botId: result.accountId,
162
- savedAt: new Date().toISOString(),
163
- });
164
- startPolling();
165
- return {
166
- content: [{ type: 'text' as const, text: `登录成功!账号 ID: ${result.accountId}` }],
167
- };
168
- } catch (err) {
169
- return {
170
- content: [{ type: 'text' as const, text: `登录失败: ${String(err)}` }],
171
- isError: true,
172
- };
173
- }
174
- }
175
-
176
- // ── reply tool ──
177
- if (name === 'reply') {
178
- const userId = args?.user_id as string | undefined;
179
- const contextToken = args?.context_token as string | undefined;
180
- const content = args?.content as string | undefined;
181
- const media = args?.media as string | undefined;
182
- const replyToMessageId = args?.reply_to_message_id as string | undefined;
183
-
184
- // 验证必填参数
185
- if (!userId || !contextToken || !content) {
186
- return {
187
- content: [{ type: 'text' as const, text: '缺少必填参数: user_id, context_token, content' }],
188
- isError: true,
189
- };
190
- }
191
-
192
- // 验证账号存在
193
- const account = getActiveAccount();
194
- if (!account) {
195
- return {
196
- content: [{ type: 'text' as const, text: '未登录,请先使用 login 工具扫码登录' }],
197
- isError: true,
198
- };
199
- }
200
-
201
- // 检查媒体文件是否存在
202
- if (media && !fs.existsSync(media)) {
203
- return {
204
- content: [{ type: 'text' as const, text: `媒体文件不存在: ${media}` }],
205
- isError: true,
206
- };
207
- }
208
-
209
- try {
210
- // 发送 typing 状态(best-effort)
211
- try {
212
- let ticket = typingTicketCache.get(userId);
213
- if (!ticket) {
214
- const config = await getConfig(account.token, userId, contextToken, account.baseUrl);
215
- ticket = config.typing_ticket ?? '';
216
- if (ticket) typingTicketCache.set(userId, ticket);
217
- }
218
- if (ticket) {
219
- await sendTyping(account.token, userId, ticket, 1, account.baseUrl);
220
- }
221
- } catch {
222
- // typing 失败不阻塞
223
- }
224
-
225
- // 清理 Markdown 并分段发送(第一段带引用回复)
226
- const plainText = stripMarkdown(content);
227
- const chunks = chunkText(plainText, 3900);
228
- for (let i = 0; i < chunks.length; i++) {
229
- const refId = i === 0 ? replyToMessageId : undefined;
230
- await sendMessage(account.token, userId, chunks[i], contextToken, account.baseUrl, refId);
231
- }
232
-
233
- // 发送媒体文件(如有)
234
- let mediaError = '';
235
- if (media) {
236
- try {
237
- await uploadMedia({
238
- token: account.token,
239
- toUser: userId,
240
- contextToken,
241
- filePath: media,
242
- baseUrl: account.baseUrl,
243
- });
244
- } catch (err) {
245
- mediaError = `(媒体发送失败: ${String(err)})`;
246
- }
247
- }
248
-
249
- // 停止 typing 状态(best-effort)
250
- try {
251
- const ticket = typingTicketCache.get(userId);
252
- if (ticket) {
253
- await sendTyping(account.token, userId, ticket, 2, account.baseUrl);
254
- }
255
- } catch {
256
- // typing 失败不阻塞
257
- }
258
-
259
- return {
260
- content: [{
261
- type: 'text' as const,
262
- text: `已发送 ${chunks.length} 段文本${media ? ' + 1 个媒体文件' : ''}${mediaError}`,
263
- }],
264
- };
265
- } catch (err) {
266
- return {
267
- content: [{ type: 'text' as const, text: `发送失败: ${String(err)}` }],
268
- isError: true,
269
- };
270
- }
271
- }
272
-
273
- return {
274
- content: [{ type: 'text' as const, text: `未知工具: ${name}` }],
275
- isError: true,
276
- };
277
- });
278
-
279
- // ─── 轮询循环 ─────────────────────────────────────────
280
-
281
- /** 消息长轮询循环 */
282
- async function pollLoop(account: AccountData): Promise<void> {
283
- let buf = loadSyncBuf();
284
- let consecutiveFailures = 0;
285
- let sessionRetries = 0;
286
- let retryDelay = INITIAL_RETRY_DELAY_MS;
287
- let nextTimeoutMs: number | undefined;
288
-
289
- while (pollingActive && !pollingAbort?.signal.aborted) {
290
- try {
291
- const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
292
-
293
- // 更新长轮询超时
294
- if (resp.longpolling_timeout_ms) {
295
- nextTimeoutMs = resp.longpolling_timeout_ms;
296
- }
297
-
298
- // 检查 API 错误
299
- if ((resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0)) {
300
- const errcode = resp.errcode ?? resp.ret ?? 0;
301
-
302
- if (errcode === SESSION_EXPIRED_ERRCODE) {
303
- sessionRetries++;
304
- process.stderr.write(`[wechat-channel] Session 过期 (${sessionRetries}/${MAX_SESSION_RETRIES})\n`);
305
-
306
- if (sessionRetries >= MAX_SESSION_RETRIES) {
307
- pollingActive = false;
308
- // 通知 Claude session 过期
309
- server.notification({
310
- method: 'notifications/message',
311
- params: {
312
- level: 'error',
313
- data: 'WeChat session expired, please use login tool to reconnect',
314
- },
315
- });
316
- return;
317
- }
318
-
319
- await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
320
- continue;
321
- }
322
-
323
- // 其他错误
324
- consecutiveFailures++;
325
- process.stderr.write(
326
- `[wechat-channel] API 错误 errcode=${errcode} errmsg=${resp.errmsg ?? ''} ` +
327
- `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
328
- );
329
-
330
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
331
- process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
332
- await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
333
- consecutiveFailures = 0;
334
- } else {
335
- await sleep(retryDelay, pollingAbort?.signal);
336
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
337
- }
338
- continue;
339
- }
340
-
341
- // 成功 → 重置计数器
342
- consecutiveFailures = 0;
343
- retryDelay = INITIAL_RETRY_DELAY_MS;
344
-
345
- // 保存 sync buf
346
- if (resp.get_updates_buf) {
347
- buf = resp.get_updates_buf;
348
- saveSyncBuf(buf);
349
- }
350
-
351
- // 处理消息(仅用户消息 message_type === 1)
352
- for (const msg of resp.msgs ?? []) {
353
- if (msg.message_type !== 1) continue;
354
-
355
- const fromUser = msg.from_user_id ?? '';
356
- const contextToken = msg.context_token ?? '';
357
-
358
- // 提取文本
359
- const text = await extractText(msg);
360
-
361
- // 缓存 typing ticket(best-effort)
362
- try {
363
- const config = await getConfig(account.token, fromUser, contextToken, account.baseUrl);
364
- if (config.typing_ticket) {
365
- typingTicketCache.set(fromUser, config.typing_ticket);
366
- }
367
- } catch {
368
- // 忽略
369
- }
370
-
371
- // 发送 typing 状态(best-effort)
372
- try {
373
- const ticket = typingTicketCache.get(fromUser);
374
- if (ticket) {
375
- await sendTyping(account.token, fromUser, ticket, 1, account.baseUrl);
376
- }
377
- } catch {
378
- // 忽略
379
- }
380
-
381
- // 通知 Claude 有新消息
382
- server.notification({
383
- method: 'notifications/claude/channel',
384
- params: {
385
- content: text,
386
- meta: {
387
- source: 'wechat',
388
- user_id: fromUser,
389
- context_token: contextToken,
390
- message_id: String(msg.message_id ?? ''),
391
- session_id: msg.session_id ?? '',
392
- },
393
- },
394
- });
395
- }
396
- } catch (err) {
397
- if (pollingAbort?.signal.aborted) return;
398
-
399
- // 网络错误 指数退避
400
- consecutiveFailures++;
401
- process.stderr.write(
402
- `[wechat-channel] 网络错误: ${String(err)} ` +
403
- `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
404
- );
405
-
406
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
407
- process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
408
- await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
409
- consecutiveFailures = 0;
410
- } else {
411
- await sleep(retryDelay, pollingAbort?.signal);
412
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
413
- }
414
- }
415
- }
416
- }
417
-
418
- // ─── 轮询控制 ─────────────────────────────────────────
419
-
420
- /** 启动消息轮询 */
421
- function startPolling(): void {
422
- const account = getActiveAccount();
423
- if (!account || pollingActive) return;
424
- pollingActive = true;
425
- pollingAbort = new AbortController();
426
- pollLoop(account).catch((err) => {
427
- if (!pollingAbort?.signal.aborted) {
428
- process.stderr.write(`[wechat-channel] Poll loop crashed: ${String(err)}\n`);
429
- }
430
- pollingActive = false;
431
- });
432
- }
433
-
434
- /** 停止消息轮询 */
435
- function stopPolling(): void {
436
- pollingActive = false;
437
- pollingAbort?.abort();
438
- pollingAbort = null;
439
- }
440
-
441
- // ─── 主入口 ───────────────────────────────────────────
442
-
443
- async function main(): Promise<void> {
444
- const transport = new StdioServerTransport();
445
- await server.connect(transport);
446
- process.stderr.write('[wechat-channel] MCP server started\n');
447
-
448
- const account = getActiveAccount();
449
- if (account) {
450
- process.stderr.write(`[wechat-channel] Found saved account: ${account.botId}\n`);
451
- startPolling();
452
- } else {
453
- process.stderr.write('[wechat-channel] No saved account. Use the login tool to connect.\n');
454
- }
455
- }
456
-
457
- main().catch((err) => {
458
- process.stderr.write(`[wechat-channel] Fatal: ${String(err)}\n`);
459
- process.exit(1);
460
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cc-wechat MCP Server 主入口
4
+ * Claude Code Channel 插件 — 微信消息桥接
5
+ */
6
+
7
+ // 代理支持(必须最先导入)
8
+ import './proxy.js';
9
+
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
+ import fs from 'node:fs';
14
+
15
+ import { getActiveAccount, saveAccount, loadSyncBuf, saveSyncBuf } from './store.js';
16
+ import { getUpdates, sendMessage, sendTyping, getConfig } from './ilink-api.js';
17
+ import { loginBrowser } from './auth.js';
18
+ import { uploadMedia, downloadMedia } from './cdn.js';
19
+ import { stripMarkdown, chunkText } from './text-utils.js';
20
+ import type { WeixinMessage, AccountData } from './types.js';
21
+ import { MessageItemType } from './types.js';
22
+
23
+ // ─── 状态变量 ─────────────────────────────────────────
24
+
25
+ let pollingActive = false;
26
+ let pollingAbort: AbortController | null = null;
27
+ const typingTicketCache = new Map<string, string>();
28
+
29
+ // ─── Session 过期处理常量 ─────────────────────────────
30
+
31
+ const SESSION_EXPIRED_ERRCODE = -14;
32
+ const MAX_SESSION_RETRIES = 3;
33
+ const MAX_CONSECUTIVE_FAILURES = 5;
34
+ const INITIAL_RETRY_DELAY_MS = 2_000;
35
+ const MAX_RETRY_DELAY_MS = 30_000;
36
+ const SESSION_PAUSE_MS = 5 * 60_000;
37
+
38
+ // ─── 辅助函数 ─────────────────────────────────────────
39
+
40
+ /** 可中断的 sleep */
41
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
42
+ return new Promise((resolve, reject) => {
43
+ const t = setTimeout(resolve, ms);
44
+ signal?.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')); }, { once: true });
45
+ });
46
+ }
47
+
48
+ /** 从消息提取可读文本(异步,支持媒体下载) */
49
+ async function extractText(msg: WeixinMessage): Promise<string> {
50
+ const parts: string[] = [];
51
+ for (const item of msg.item_list ?? []) {
52
+ const t = item.type ?? 0;
53
+
54
+ if (t === MessageItemType.TEXT) {
55
+ if (item.text_item?.text) {
56
+ // 提取引用回复内容
57
+ if (item.ref_msg) {
58
+ const refTitle = item.ref_msg.title ?? '';
59
+ const refText = item.ref_msg.message_item?.text_item?.text ?? '';
60
+ const refContent = refTitle || refText;
61
+ if (refContent) {
62
+ parts.push(`[引用: ${refContent}]`);
63
+ }
64
+ }
65
+ parts.push(item.text_item.text);
66
+ }
67
+ } else if (t === MessageItemType.IMAGE) {
68
+ let desc = '[图片]';
69
+ if (item.image_item?.media?.encrypt_query_param && item.image_item?.media?.aes_key) {
70
+ try {
71
+ const filePath = await downloadMedia({
72
+ encryptQueryParam: item.image_item.media.encrypt_query_param,
73
+ aesKeyBase64: item.image_item.media.aes_key,
74
+ });
75
+ desc += `\n[附件: ${filePath}]`;
76
+ } catch {
77
+ // 下载失败不阻塞消息处理
78
+ }
79
+ }
80
+ parts.push(desc);
81
+ } else if (t === MessageItemType.VOICE) {
82
+ parts.push(`[语音] ${item.voice_item?.text ?? ''}`);
83
+ } else if (t === MessageItemType.FILE) {
84
+ let desc = `[文件: ${item.file_item?.file_name ?? 'unknown'}]`;
85
+ if (item.file_item?.media?.encrypt_query_param && item.file_item?.media?.aes_key) {
86
+ try {
87
+ const filePath = await downloadMedia({
88
+ encryptQueryParam: item.file_item.media.encrypt_query_param,
89
+ aesKeyBase64: item.file_item.media.aes_key,
90
+ fileName: item.file_item.file_name,
91
+ });
92
+ desc += `\n[附件: ${filePath}]`;
93
+ } catch {
94
+ // 下载失败不阻塞消息处理
95
+ }
96
+ }
97
+ parts.push(desc);
98
+ } else if (t === MessageItemType.VIDEO) {
99
+ parts.push('[视频]');
100
+ }
101
+ }
102
+ return parts.join('\n') || '[空消息]';
103
+ }
104
+
105
+ // ─── MCP Server 创建 ─────────────────────────────────
106
+
107
+ const server = new Server(
108
+ { name: 'wechat-channel', version: '0.1.0' },
109
+ {
110
+ capabilities: {
111
+ experimental: { 'claude/channel': {} },
112
+ tools: {},
113
+ },
114
+ instructions: `Messages arrive as <channel source="wechat-channel" user_id="..." context_token="..." message_id="...">.
115
+ Reply using the reply tool. Pass user_id and context_token from the channel tag.
116
+ For media: set media to an absolute local file path to send image/video/file.
117
+ For quote reply: set reply_to_message_id to the message_id from the channel tag to send a quoted reply.
118
+ IMPORTANT: Always use the reply tool to respond to WeChat messages. Do not just output text.`,
119
+ },
120
+ );
121
+
122
+ // ─── Tools — ListToolsRequestSchema ──────────────────
123
+
124
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
125
+ tools: [
126
+ {
127
+ name: 'login',
128
+ description: '扫码登录微信。首次使用或 session 过期后运行。',
129
+ inputSchema: {
130
+ type: 'object' as const,
131
+ properties: {},
132
+ },
133
+ },
134
+ {
135
+ name: 'reply',
136
+ description: '回复微信消息',
137
+ inputSchema: {
138
+ type: 'object' as const,
139
+ properties: {
140
+ user_id: { type: 'string', description: '微信用户 ID(来自消息 meta 的 user_id)' },
141
+ context_token: { type: 'string', description: '会话上下文令牌(来自消息 meta 的 context_token)' },
142
+ content: { type: 'string', description: '回复文本内容' },
143
+ media: { type: 'string', description: '可选:本地文件绝对路径,发送图片/视频/文件' },
144
+ reply_to_message_id: { type: 'string', description: '可选:引用回复的原消息 ID(来自 meta 的 message_id)' },
145
+ },
146
+ required: ['user_id', 'context_token', 'content'],
147
+ },
148
+ },
149
+ ],
150
+ }));
151
+
152
+ // ─── Tools CallToolRequestSchema ───────────────────
153
+
154
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
155
+ const { name, arguments: args } = request.params;
156
+
157
+ // ── login tool ──
158
+ if (name === 'login') {
159
+ try {
160
+ const result = await loginBrowser();
161
+ saveAccount({
162
+ token: result.token,
163
+ baseUrl: result.baseUrl ?? '',
164
+ botId: result.accountId,
165
+ savedAt: new Date().toISOString(),
166
+ });
167
+ startPolling();
168
+ return {
169
+ content: [{ type: 'text' as const, text: `登录成功!账号 ID: ${result.accountId}` }],
170
+ };
171
+ } catch (err) {
172
+ return {
173
+ content: [{ type: 'text' as const, text: `登录失败: ${String(err)}` }],
174
+ isError: true,
175
+ };
176
+ }
177
+ }
178
+
179
+ // ── reply tool ──
180
+ if (name === 'reply') {
181
+ const userId = args?.user_id as string | undefined;
182
+ const contextToken = args?.context_token as string | undefined;
183
+ const content = args?.content as string | undefined;
184
+ const media = args?.media as string | undefined;
185
+ const replyToMessageId = args?.reply_to_message_id as string | undefined;
186
+
187
+ // 验证必填参数
188
+ if (!userId || !contextToken || !content) {
189
+ return {
190
+ content: [{ type: 'text' as const, text: '缺少必填参数: user_id, context_token, content' }],
191
+ isError: true,
192
+ };
193
+ }
194
+
195
+ // 验证账号存在
196
+ const account = getActiveAccount();
197
+ if (!account) {
198
+ return {
199
+ content: [{ type: 'text' as const, text: '未登录,请先使用 login 工具扫码登录' }],
200
+ isError: true,
201
+ };
202
+ }
203
+
204
+ // 检查媒体文件是否存在
205
+ if (media && !fs.existsSync(media)) {
206
+ return {
207
+ content: [{ type: 'text' as const, text: `媒体文件不存在: ${media}` }],
208
+ isError: true,
209
+ };
210
+ }
211
+
212
+ try {
213
+ // 发送 typing 状态(best-effort)
214
+ try {
215
+ let ticket = typingTicketCache.get(userId);
216
+ if (!ticket) {
217
+ const config = await getConfig(account.token, userId, contextToken, account.baseUrl);
218
+ ticket = config.typing_ticket ?? '';
219
+ if (ticket) typingTicketCache.set(userId, ticket);
220
+ }
221
+ if (ticket) {
222
+ await sendTyping(account.token, userId, ticket, 1, account.baseUrl);
223
+ }
224
+ } catch {
225
+ // typing 失败不阻塞
226
+ }
227
+
228
+ // 清理 Markdown 并分段发送(第一段带引用回复)
229
+ const plainText = stripMarkdown(content);
230
+ const chunks = chunkText(plainText, 3900);
231
+ for (let i = 0; i < chunks.length; i++) {
232
+ const refId = i === 0 ? replyToMessageId : undefined;
233
+ await sendMessage(account.token, userId, chunks[i], contextToken, account.baseUrl, refId);
234
+ }
235
+
236
+ // 发送媒体文件(如有)
237
+ let mediaError = '';
238
+ if (media) {
239
+ try {
240
+ await uploadMedia({
241
+ token: account.token,
242
+ toUser: userId,
243
+ contextToken,
244
+ filePath: media,
245
+ baseUrl: account.baseUrl,
246
+ });
247
+ } catch (err) {
248
+ mediaError = `(媒体发送失败: ${String(err)})`;
249
+ }
250
+ }
251
+
252
+ // 停止 typing 状态(best-effort)
253
+ try {
254
+ const ticket = typingTicketCache.get(userId);
255
+ if (ticket) {
256
+ await sendTyping(account.token, userId, ticket, 2, account.baseUrl);
257
+ }
258
+ } catch {
259
+ // typing 失败不阻塞
260
+ }
261
+
262
+ return {
263
+ content: [{
264
+ type: 'text' as const,
265
+ text: `已发送 ${chunks.length} 段文本${media ? ' + 1 个媒体文件' : ''}${mediaError}`,
266
+ }],
267
+ };
268
+ } catch (err) {
269
+ return {
270
+ content: [{ type: 'text' as const, text: `发送失败: ${String(err)}` }],
271
+ isError: true,
272
+ };
273
+ }
274
+ }
275
+
276
+ return {
277
+ content: [{ type: 'text' as const, text: `未知工具: ${name}` }],
278
+ isError: true,
279
+ };
280
+ });
281
+
282
+ // ─── 轮询循环 ─────────────────────────────────────────
283
+
284
+ /** 消息长轮询循环 */
285
+ async function pollLoop(account: AccountData): Promise<void> {
286
+ let buf = loadSyncBuf();
287
+ let consecutiveFailures = 0;
288
+ let sessionRetries = 0;
289
+ let retryDelay = INITIAL_RETRY_DELAY_MS;
290
+ let nextTimeoutMs: number | undefined;
291
+
292
+ while (pollingActive && !pollingAbort?.signal.aborted) {
293
+ try {
294
+ const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
295
+
296
+ // 更新长轮询超时
297
+ if (resp.longpolling_timeout_ms) {
298
+ nextTimeoutMs = resp.longpolling_timeout_ms;
299
+ }
300
+
301
+ // 检查 API 错误
302
+ if ((resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0)) {
303
+ const errcode = resp.errcode ?? resp.ret ?? 0;
304
+
305
+ if (errcode === SESSION_EXPIRED_ERRCODE) {
306
+ sessionRetries++;
307
+ process.stderr.write(`[wechat-channel] Session 过期 (${sessionRetries}/${MAX_SESSION_RETRIES})\n`);
308
+
309
+ if (sessionRetries >= MAX_SESSION_RETRIES) {
310
+ pollingActive = false;
311
+ // 通知 Claude session 过期
312
+ server.notification({
313
+ method: 'notifications/message',
314
+ params: {
315
+ level: 'error',
316
+ data: 'WeChat session expired, please use login tool to reconnect',
317
+ },
318
+ });
319
+ return;
320
+ }
321
+
322
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
323
+ continue;
324
+ }
325
+
326
+ // 其他错误
327
+ consecutiveFailures++;
328
+ process.stderr.write(
329
+ `[wechat-channel] API 错误 errcode=${errcode} errmsg=${resp.errmsg ?? ''} ` +
330
+ `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
331
+ );
332
+
333
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
334
+ process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
335
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
336
+ consecutiveFailures = 0;
337
+ } else {
338
+ await sleep(retryDelay, pollingAbort?.signal);
339
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
340
+ }
341
+ continue;
342
+ }
343
+
344
+ // 成功 → 重置计数器
345
+ consecutiveFailures = 0;
346
+ retryDelay = INITIAL_RETRY_DELAY_MS;
347
+
348
+ // 保存 sync buf
349
+ if (resp.get_updates_buf) {
350
+ buf = resp.get_updates_buf;
351
+ saveSyncBuf(buf);
352
+ }
353
+
354
+ // 处理消息(仅用户消息 message_type === 1)
355
+ for (const msg of resp.msgs ?? []) {
356
+ if (msg.message_type !== 1) continue;
357
+
358
+ const fromUser = msg.from_user_id ?? '';
359
+ const contextToken = msg.context_token ?? '';
360
+
361
+ // 提取文本
362
+ const text = await extractText(msg);
363
+
364
+ // 缓存 typing ticket(best-effort)
365
+ try {
366
+ const config = await getConfig(account.token, fromUser, contextToken, account.baseUrl);
367
+ if (config.typing_ticket) {
368
+ typingTicketCache.set(fromUser, config.typing_ticket);
369
+ }
370
+ } catch {
371
+ // 忽略
372
+ }
373
+
374
+ // 发送 typing 状态(best-effort)
375
+ try {
376
+ const ticket = typingTicketCache.get(fromUser);
377
+ if (ticket) {
378
+ await sendTyping(account.token, fromUser, ticket, 1, account.baseUrl);
379
+ }
380
+ } catch {
381
+ // 忽略
382
+ }
383
+
384
+ // 通知 Claude 有新消息
385
+ server.notification({
386
+ method: 'notifications/claude/channel',
387
+ params: {
388
+ content: text,
389
+ meta: {
390
+ source: 'wechat',
391
+ user_id: fromUser,
392
+ context_token: contextToken,
393
+ message_id: String(msg.message_id ?? ''),
394
+ session_id: msg.session_id ?? '',
395
+ },
396
+ },
397
+ });
398
+ }
399
+ } catch (err) {
400
+ if (pollingAbort?.signal.aborted) return;
401
+
402
+ // 网络错误 指数退避
403
+ consecutiveFailures++;
404
+ process.stderr.write(
405
+ `[wechat-channel] 网络错误: ${String(err)} ` +
406
+ `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
407
+ );
408
+
409
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
410
+ process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
411
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
412
+ consecutiveFailures = 0;
413
+ } else {
414
+ await sleep(retryDelay, pollingAbort?.signal);
415
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ // ─── 轮询控制 ─────────────────────────────────────────
422
+
423
+ /** 启动消息轮询 */
424
+ function startPolling(): void {
425
+ const account = getActiveAccount();
426
+ if (!account || pollingActive) return;
427
+ pollingActive = true;
428
+ pollingAbort = new AbortController();
429
+ pollLoop(account).catch((err) => {
430
+ if (!pollingAbort?.signal.aborted) {
431
+ process.stderr.write(`[wechat-channel] Poll loop crashed: ${String(err)}\n`);
432
+ }
433
+ pollingActive = false;
434
+ });
435
+ }
436
+
437
+ /** 停止消息轮询 */
438
+ function stopPolling(): void {
439
+ pollingActive = false;
440
+ pollingAbort?.abort();
441
+ pollingAbort = null;
442
+ }
443
+
444
+ // ─── 主入口 ───────────────────────────────────────────
445
+
446
+ async function main(): Promise<void> {
447
+ const transport = new StdioServerTransport();
448
+ await server.connect(transport);
449
+ process.stderr.write('[wechat-channel] MCP server started\n');
450
+
451
+ const account = getActiveAccount();
452
+ if (account) {
453
+ process.stderr.write(`[wechat-channel] Found saved account: ${account.botId}\n`);
454
+ startPolling();
455
+ } else {
456
+ process.stderr.write('[wechat-channel] No saved account. Use the login tool to connect.\n');
457
+ }
458
+ }
459
+
460
+ main().catch((err) => {
461
+ process.stderr.write(`[wechat-channel] Fatal: ${String(err)}\n`);
462
+ process.exit(1);
463
+ });