@aster110/cc2wechat 1.0.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/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +6 -0
- package/EXPERIENCE.md +148 -0
- package/README.md +66 -0
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +230 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/dist/cli.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +412 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.js +61 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/dist/wechat-api.d.ts +33 -0
- package/dist/wechat-api.js +313 -0
- package/dist/wechat-api.js.map +1 -0
- package/package.json +28 -0
- package/skills/wechat-reply/SKILL.md +21 -0
- package/src/auth.ts +258 -0
- package/src/cli.ts +133 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/server.ts +479 -0
- package/src/store.ts +79 -0
- package/src/types.ts +144 -0
- package/src/wechat-api.ts +405 -0
- package/tsconfig.json +17 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { loginWithQRWeb } from './auth.js';
|
|
7
|
+
import { saveAccount, getActiveAccount } from './store.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const serverPath = path.join(__dirname, 'server.js');
|
|
11
|
+
|
|
12
|
+
const command = process.argv[2];
|
|
13
|
+
|
|
14
|
+
function printUsage(): void {
|
|
15
|
+
console.log(`
|
|
16
|
+
đĻ wechat-claude â WeChat channel for Claude Code
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
npx @aster110/wechat-claude install Setup: register MCP + scan QR login
|
|
20
|
+
npx @aster110/wechat-claude login Re-login (scan QR code)
|
|
21
|
+
npx @aster110/wechat-claude status Check connection status
|
|
22
|
+
npx @aster110/wechat-claude help Show this help
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function install(): Promise<void> {
|
|
27
|
+
console.log('\n đĻ wechat-claude installer\n');
|
|
28
|
+
|
|
29
|
+
// Step 1: Register MCP server
|
|
30
|
+
console.log(' [1/3] Registering MCP server...');
|
|
31
|
+
try {
|
|
32
|
+
execSync(
|
|
33
|
+
`claude mcp add -s user wechat-channel node ${serverPath}`,
|
|
34
|
+
{ stdio: 'pipe' },
|
|
35
|
+
);
|
|
36
|
+
console.log(' â
MCP server registered (user-level)\n');
|
|
37
|
+
} catch {
|
|
38
|
+
// May already exist, try remove + add
|
|
39
|
+
try {
|
|
40
|
+
execSync(`claude mcp remove -s user wechat-channel`, { stdio: 'pipe' });
|
|
41
|
+
execSync(
|
|
42
|
+
`claude mcp add -s user wechat-channel node ${serverPath}`,
|
|
43
|
+
{ stdio: 'pipe' },
|
|
44
|
+
);
|
|
45
|
+
console.log(' â
MCP server updated (user-level)\n');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(' â ī¸ Failed to register MCP server. You may need to add it manually:');
|
|
48
|
+
console.log(` claude mcp add -s user wechat-channel node ${serverPath}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Step 2: QR Login
|
|
53
|
+
console.log(' [2/3] WeChat QR login...');
|
|
54
|
+
const existing = getActiveAccount();
|
|
55
|
+
if (existing) {
|
|
56
|
+
console.log(` âšī¸ Found existing account: ${existing.accountId}`);
|
|
57
|
+
console.log(' Skipping login. Run "npx @aster110/wechat-claude login" to re-login.\n');
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
const result = await loginWithQRWeb();
|
|
61
|
+
saveAccount({
|
|
62
|
+
accountId: result.accountId.replace(/@/g, '-').replace(/\./g, '-'),
|
|
63
|
+
token: result.token,
|
|
64
|
+
baseUrl: result.baseUrl,
|
|
65
|
+
savedAt: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
console.log(` â
Login successful! Account: ${result.accountId}\n`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(` â Login failed: ${err}`);
|
|
70
|
+
console.log(' Run "npx @aster110/wechat-claude login" to retry.\n');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Step 3: Print next steps
|
|
75
|
+
console.log(' [3/3] Setup complete!\n');
|
|
76
|
+
console.log(' Next steps:');
|
|
77
|
+
console.log(' 1. Start Claude Code with WeChat channel:');
|
|
78
|
+
console.log(' claude --dangerously-load-development-channels server:wechat-channel\n');
|
|
79
|
+
console.log(' 2. Send a message to your WeChat â Claude Code will auto-reply!\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function login(): Promise<void> {
|
|
83
|
+
console.log('\n đĻ WeChat QR Login\n');
|
|
84
|
+
try {
|
|
85
|
+
const result = await loginWithQRWeb();
|
|
86
|
+
saveAccount({
|
|
87
|
+
accountId: result.accountId.replace(/@/g, '-').replace(/\./g, '-'),
|
|
88
|
+
token: result.token,
|
|
89
|
+
baseUrl: result.baseUrl,
|
|
90
|
+
savedAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
console.log(`\n â
Login successful! Account: ${result.accountId}\n`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error(`\n â Login failed: ${err}\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function status(): void {
|
|
100
|
+
const account = getActiveAccount();
|
|
101
|
+
if (account) {
|
|
102
|
+
console.log(`\n đĻ WeChat Channel Status\n`);
|
|
103
|
+
console.log(` Account: ${account.accountId}`);
|
|
104
|
+
console.log(` Token: ${account.token.slice(0, 10)}...`);
|
|
105
|
+
console.log(` Base URL: ${account.baseUrl || 'https://ilinkai.weixin.qq.com'}`);
|
|
106
|
+
console.log(` Saved: ${account.savedAt}\n`);
|
|
107
|
+
} else {
|
|
108
|
+
console.log('\n â ī¸ Not logged in. Run: npx @aster110/wechat-claude install\n');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
switch (command) {
|
|
113
|
+
case 'install':
|
|
114
|
+
case 'setup':
|
|
115
|
+
install().catch(console.error);
|
|
116
|
+
break;
|
|
117
|
+
case 'login':
|
|
118
|
+
login().catch(console.error);
|
|
119
|
+
break;
|
|
120
|
+
case 'status':
|
|
121
|
+
status();
|
|
122
|
+
break;
|
|
123
|
+
case 'help':
|
|
124
|
+
case '--help':
|
|
125
|
+
case '-h':
|
|
126
|
+
case undefined:
|
|
127
|
+
printUsage();
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
console.error(` Unknown command: ${command}`);
|
|
131
|
+
printUsage();
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
|
|
10
|
+
import { loginWithQRWeb } from './auth.js';
|
|
11
|
+
import { getActiveAccount, saveAccount, loadSyncBuf, saveSyncBuf } from './store.js';
|
|
12
|
+
import {
|
|
13
|
+
getUpdates,
|
|
14
|
+
sendMessage,
|
|
15
|
+
sendTyping,
|
|
16
|
+
getConfig,
|
|
17
|
+
uploadAndSendMedia,
|
|
18
|
+
} from './wechat-api.js';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import type { WeixinMessage } from './types.js';
|
|
21
|
+
import { MessageItemType } from './types.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// State
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
let pollingActive = false;
|
|
28
|
+
let pollingAbort: AbortController | null = null;
|
|
29
|
+
|
|
30
|
+
/** Cache: userId -> typing_ticket */
|
|
31
|
+
const typingTicketCache = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Message text extraction
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function extractText(msg: WeixinMessage): string {
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
for (const item of msg.item_list ?? []) {
|
|
40
|
+
if (item.type === MessageItemType.TEXT && item.text_item?.text) {
|
|
41
|
+
parts.push(item.text_item.text);
|
|
42
|
+
} else if (item.type === MessageItemType.IMAGE) {
|
|
43
|
+
parts.push('[Image]');
|
|
44
|
+
} else if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
|
45
|
+
parts.push(`[Voice] ${item.voice_item.text}`);
|
|
46
|
+
} else if (item.type === MessageItemType.FILE && item.file_item?.file_name) {
|
|
47
|
+
parts.push(`[File: ${item.file_item.file_name}]`);
|
|
48
|
+
} else if (item.type === MessageItemType.VIDEO) {
|
|
49
|
+
parts.push('[Video]');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return parts.join('\n') || '[Empty message]';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Text chunking for WeChat 4000-char limit
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const MAX_CHUNK_LENGTH = 3900;
|
|
60
|
+
|
|
61
|
+
function chunkText(text: string): string[] {
|
|
62
|
+
if (text.length <= MAX_CHUNK_LENGTH) return [text];
|
|
63
|
+
const chunks: string[] = [];
|
|
64
|
+
let remaining = text;
|
|
65
|
+
while (remaining.length > 0) {
|
|
66
|
+
if (remaining.length <= MAX_CHUNK_LENGTH) {
|
|
67
|
+
chunks.push(remaining);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
// Try to break at newline
|
|
71
|
+
let breakAt = remaining.lastIndexOf('\n', MAX_CHUNK_LENGTH);
|
|
72
|
+
if (breakAt < MAX_CHUNK_LENGTH * 0.5) {
|
|
73
|
+
// No good newline break, try space
|
|
74
|
+
breakAt = remaining.lastIndexOf(' ', MAX_CHUNK_LENGTH);
|
|
75
|
+
}
|
|
76
|
+
if (breakAt < MAX_CHUNK_LENGTH * 0.3) {
|
|
77
|
+
breakAt = MAX_CHUNK_LENGTH;
|
|
78
|
+
}
|
|
79
|
+
chunks.push(remaining.slice(0, breakAt));
|
|
80
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
81
|
+
}
|
|
82
|
+
return chunks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Strip markdown for WeChat plain text
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function stripMarkdown(text: string): string {
|
|
90
|
+
let result = text;
|
|
91
|
+
// Code blocks: strip fences, keep content
|
|
92
|
+
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
|
|
93
|
+
// Images: remove
|
|
94
|
+
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
95
|
+
// Links: keep display text
|
|
96
|
+
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
|
|
97
|
+
// Bold/italic
|
|
98
|
+
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
99
|
+
result = result.replace(/\*(.+?)\*/g, '$1');
|
|
100
|
+
result = result.replace(/__(.+?)__/g, '$1');
|
|
101
|
+
result = result.replace(/_(.+?)_/g, '$1');
|
|
102
|
+
// Headings
|
|
103
|
+
result = result.replace(/^#{1,6}\s+/gm, '');
|
|
104
|
+
// Horizontal rules
|
|
105
|
+
result = result.replace(/^[-*_]{3,}$/gm, '');
|
|
106
|
+
// Blockquotes
|
|
107
|
+
result = result.replace(/^>\s?/gm, '');
|
|
108
|
+
return result.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// MCP Server
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const server = new Server(
|
|
116
|
+
{ name: 'wechat-channel', version: '1.0.0' },
|
|
117
|
+
{
|
|
118
|
+
capabilities: {
|
|
119
|
+
experimental: { 'claude/channel': {} },
|
|
120
|
+
tools: {},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// -- Tools --
|
|
126
|
+
|
|
127
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
128
|
+
tools: [
|
|
129
|
+
{
|
|
130
|
+
name: 'reply',
|
|
131
|
+
description:
|
|
132
|
+
'Reply to a WeChat message. Supports text and image/file. Set media to a local file path to send an image or file.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object' as const,
|
|
135
|
+
properties: {
|
|
136
|
+
user_id: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'The WeChat user ID to reply to (from_user_id from the incoming message)',
|
|
139
|
+
},
|
|
140
|
+
context_token: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: 'The context_token from the incoming message (required for reply association)',
|
|
143
|
+
},
|
|
144
|
+
content: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description: 'Text content',
|
|
147
|
+
},
|
|
148
|
+
media: {
|
|
149
|
+
type: 'string',
|
|
150
|
+
description: 'Optional: absolute path to a local file (image/video/file) to send',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
required: ['user_id', 'context_token', 'content'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'login',
|
|
158
|
+
description:
|
|
159
|
+
'Login to WeChat by scanning a QR code. Run this first if not already logged in.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object' as const,
|
|
162
|
+
properties: {},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
169
|
+
const { name, arguments: args } = request.params;
|
|
170
|
+
|
|
171
|
+
if (name === 'login') {
|
|
172
|
+
try {
|
|
173
|
+
const result = await loginWithQRWeb();
|
|
174
|
+
saveAccount({
|
|
175
|
+
accountId: result.accountId,
|
|
176
|
+
token: result.token,
|
|
177
|
+
baseUrl: result.baseUrl,
|
|
178
|
+
savedAt: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
// Start polling after login
|
|
181
|
+
startPolling();
|
|
182
|
+
return {
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: 'text' as const,
|
|
186
|
+
text: `WeChat login successful! Account: ${result.accountId}. Polling started.`,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
content: [
|
|
193
|
+
{ type: 'text' as const, text: `Login failed: ${String(err)}` },
|
|
194
|
+
],
|
|
195
|
+
isError: true,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (name === 'reply') {
|
|
201
|
+
const userId = (args as Record<string, string>).user_id;
|
|
202
|
+
const contextToken = (args as Record<string, string>).context_token;
|
|
203
|
+
const content = (args as Record<string, string>).content;
|
|
204
|
+
const media = (args as Record<string, string>).media;
|
|
205
|
+
|
|
206
|
+
if (!userId || !contextToken || !content) {
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{ type: 'text' as const, text: 'Missing required fields: user_id, context_token, content' },
|
|
210
|
+
],
|
|
211
|
+
isError: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Validate media path if provided
|
|
216
|
+
if (media && !fs.existsSync(media)) {
|
|
217
|
+
return {
|
|
218
|
+
content: [
|
|
219
|
+
{ type: 'text' as const, text: `Media file not found: ${media}` },
|
|
220
|
+
],
|
|
221
|
+
isError: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const account = getActiveAccount();
|
|
226
|
+
if (!account) {
|
|
227
|
+
return {
|
|
228
|
+
content: [
|
|
229
|
+
{ type: 'text' as const, text: 'Not logged in. Use the login tool first.' },
|
|
230
|
+
],
|
|
231
|
+
isError: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
// Send typing indicator
|
|
237
|
+
const ticket = typingTicketCache.get(userId);
|
|
238
|
+
if (ticket) {
|
|
239
|
+
await sendTyping(account.token, userId, ticket, 1, account.baseUrl).catch(() => {});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Strip markdown and chunk
|
|
243
|
+
const plainText = stripMarkdown(content);
|
|
244
|
+
const chunks = chunkText(plainText);
|
|
245
|
+
|
|
246
|
+
for (const chunk of chunks) {
|
|
247
|
+
await sendMessage(account.token, userId, chunk, contextToken, account.baseUrl);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Send media if provided
|
|
251
|
+
let mediaSent = false;
|
|
252
|
+
if (media) {
|
|
253
|
+
await uploadAndSendMedia({
|
|
254
|
+
token: account.token,
|
|
255
|
+
toUser: userId,
|
|
256
|
+
contextToken,
|
|
257
|
+
filePath: media,
|
|
258
|
+
baseUrl: account.baseUrl,
|
|
259
|
+
});
|
|
260
|
+
mediaSent = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Cancel typing
|
|
264
|
+
if (ticket) {
|
|
265
|
+
await sendTyping(account.token, userId, ticket, 2, account.baseUrl).catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const parts = [`Reply sent to ${userId} (${chunks.length} chunk${chunks.length > 1 ? 's' : ''})`];
|
|
269
|
+
if (mediaSent) parts.push(`Media sent: ${media}`);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
content: [
|
|
273
|
+
{
|
|
274
|
+
type: 'text' as const,
|
|
275
|
+
text: parts.join('. '),
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{ type: 'text' as const, text: `Failed to send reply: ${String(err)}` },
|
|
283
|
+
],
|
|
284
|
+
isError: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
|
|
291
|
+
isError: true,
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Long-polling loop
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
const SESSION_EXPIRED_ERRCODE = -14;
|
|
300
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
301
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
302
|
+
const RETRY_DELAY_MS = 2_000;
|
|
303
|
+
const SESSION_PAUSE_MS = 5 * 60_000;
|
|
304
|
+
|
|
305
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const t = setTimeout(resolve, ms);
|
|
308
|
+
signal?.addEventListener(
|
|
309
|
+
'abort',
|
|
310
|
+
() => {
|
|
311
|
+
clearTimeout(t);
|
|
312
|
+
reject(new Error('aborted'));
|
|
313
|
+
},
|
|
314
|
+
{ once: true },
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function pollLoop(account: { token: string; accountId: string; baseUrl?: string }): Promise<void> {
|
|
320
|
+
let buf = loadSyncBuf(account.accountId);
|
|
321
|
+
let consecutiveFailures = 0;
|
|
322
|
+
let nextTimeoutMs = 35_000;
|
|
323
|
+
|
|
324
|
+
process.stderr.write(`[wechat-channel] Polling started for account ${account.accountId}\n`);
|
|
325
|
+
|
|
326
|
+
while (pollingActive && !pollingAbort?.signal.aborted) {
|
|
327
|
+
try {
|
|
328
|
+
const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
|
|
329
|
+
|
|
330
|
+
// Update timeout if server suggests one
|
|
331
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
332
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check for API errors
|
|
336
|
+
const isApiError =
|
|
337
|
+
(resp.ret !== undefined && resp.ret !== 0) ||
|
|
338
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
339
|
+
|
|
340
|
+
if (isApiError) {
|
|
341
|
+
const isSessionExpired =
|
|
342
|
+
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
343
|
+
|
|
344
|
+
if (isSessionExpired) {
|
|
345
|
+
process.stderr.write(
|
|
346
|
+
`[wechat-channel] Session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing ${Math.ceil(SESSION_PAUSE_MS / 60_000)} min\n`,
|
|
347
|
+
);
|
|
348
|
+
consecutiveFailures = 0;
|
|
349
|
+
await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
consecutiveFailures++;
|
|
354
|
+
process.stderr.write(
|
|
355
|
+
`[wechat-channel] getUpdates error: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ''} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
|
|
356
|
+
);
|
|
357
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
358
|
+
consecutiveFailures = 0;
|
|
359
|
+
await sleep(BACKOFF_DELAY_MS, pollingAbort?.signal);
|
|
360
|
+
} else {
|
|
361
|
+
await sleep(RETRY_DELAY_MS, pollingAbort?.signal);
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
consecutiveFailures = 0;
|
|
367
|
+
|
|
368
|
+
// Save sync buf
|
|
369
|
+
if (resp.get_updates_buf != null && resp.get_updates_buf !== '') {
|
|
370
|
+
saveSyncBuf(account.accountId, resp.get_updates_buf);
|
|
371
|
+
buf = resp.get_updates_buf;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Process messages
|
|
375
|
+
const msgs = resp.msgs ?? [];
|
|
376
|
+
for (const msg of msgs) {
|
|
377
|
+
// Only process user messages (message_type === 1)
|
|
378
|
+
if (msg.message_type !== 1) continue;
|
|
379
|
+
|
|
380
|
+
const text = extractText(msg);
|
|
381
|
+
const fromUser = msg.from_user_id ?? 'unknown';
|
|
382
|
+
const contextToken = msg.context_token ?? '';
|
|
383
|
+
|
|
384
|
+
process.stderr.write(`[wechat-channel] Message from ${fromUser}: ${text.slice(0, 100)}\n`);
|
|
385
|
+
|
|
386
|
+
// Cache typing ticket for this user
|
|
387
|
+
try {
|
|
388
|
+
const cfg = await getConfig(account.token, fromUser, contextToken, account.baseUrl);
|
|
389
|
+
if (cfg.typing_ticket) {
|
|
390
|
+
typingTicketCache.set(fromUser, cfg.typing_ticket);
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// non-critical
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Send typing indicator
|
|
397
|
+
const ticket = typingTicketCache.get(fromUser);
|
|
398
|
+
if (ticket) {
|
|
399
|
+
await sendTyping(account.token, fromUser, ticket, 1, account.baseUrl).catch(() => {});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Push to Claude Code via channel notification
|
|
403
|
+
server.notification({
|
|
404
|
+
method: 'notifications/claude/channel',
|
|
405
|
+
params: {
|
|
406
|
+
content: `${text}\n\n[System: Reply via the "reply" tool. user_id and context_token are in the message metadata above.]`,
|
|
407
|
+
meta: {
|
|
408
|
+
source: 'wechat',
|
|
409
|
+
sender: fromUser,
|
|
410
|
+
user_id: fromUser,
|
|
411
|
+
context_token: contextToken,
|
|
412
|
+
message_id: String(msg.message_id ?? ''),
|
|
413
|
+
session_id: msg.session_id ?? '',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (pollingAbort?.signal.aborted) return;
|
|
420
|
+
consecutiveFailures++;
|
|
421
|
+
process.stderr.write(
|
|
422
|
+
`[wechat-channel] Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}\n`,
|
|
423
|
+
);
|
|
424
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
425
|
+
consecutiveFailures = 0;
|
|
426
|
+
await sleep(BACKOFF_DELAY_MS, pollingAbort?.signal);
|
|
427
|
+
} else {
|
|
428
|
+
await sleep(RETRY_DELAY_MS, pollingAbort?.signal);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function startPolling(): void {
|
|
435
|
+
const account = getActiveAccount();
|
|
436
|
+
if (!account) {
|
|
437
|
+
process.stderr.write('[wechat-channel] No account found, skipping polling\n');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (pollingActive) {
|
|
441
|
+
process.stderr.write('[wechat-channel] Polling already active\n');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
pollingActive = true;
|
|
446
|
+
pollingAbort = new AbortController();
|
|
447
|
+
|
|
448
|
+
// Run poll loop in background (don't await)
|
|
449
|
+
pollLoop(account).catch((err) => {
|
|
450
|
+
if (!pollingAbort?.signal.aborted) {
|
|
451
|
+
process.stderr.write(`[wechat-channel] Poll loop crashed: ${String(err)}\n`);
|
|
452
|
+
}
|
|
453
|
+
pollingActive = false;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Main
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
async function main(): Promise<void> {
|
|
462
|
+
const transport = new StdioServerTransport();
|
|
463
|
+
await server.connect(transport);
|
|
464
|
+
process.stderr.write('[wechat-channel] MCP server started\n');
|
|
465
|
+
|
|
466
|
+
// Auto-start polling if we have saved credentials
|
|
467
|
+
const account = getActiveAccount();
|
|
468
|
+
if (account) {
|
|
469
|
+
process.stderr.write(`[wechat-channel] Found saved account: ${account.accountId}\n`);
|
|
470
|
+
startPolling();
|
|
471
|
+
} else {
|
|
472
|
+
process.stderr.write('[wechat-channel] No saved account. Use the login tool to connect.\n');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
main().catch((err) => {
|
|
477
|
+
process.stderr.write(`[wechat-channel] Fatal: ${String(err)}\n`);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CHANNEL_DIR = path.join(os.homedir(), '.claude', 'channels', 'wechat-channel');
|
|
6
|
+
|
|
7
|
+
function ensureDir(dir: string): void {
|
|
8
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Account credentials
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface AccountData {
|
|
16
|
+
accountId: string;
|
|
17
|
+
token: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
savedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function accountsFilePath(): string {
|
|
23
|
+
return path.join(CHANNEL_DIR, 'accounts.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadAccounts(): AccountData[] {
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(accountsFilePath(), 'utf-8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveAccount(account: AccountData): void {
|
|
37
|
+
ensureDir(CHANNEL_DIR);
|
|
38
|
+
const accounts = loadAccounts().filter((a) => a.accountId !== account.accountId);
|
|
39
|
+
accounts.push(account);
|
|
40
|
+
const filePath = accountsFilePath();
|
|
41
|
+
fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2), 'utf-8');
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(filePath, 0o600);
|
|
44
|
+
} catch {
|
|
45
|
+
// best-effort
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getActiveAccount(): AccountData | null {
|
|
50
|
+
const accounts = loadAccounts();
|
|
51
|
+
return accounts.length > 0 ? accounts[accounts.length - 1]! : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function removeAccount(accountId: string): void {
|
|
55
|
+
const accounts = loadAccounts().filter((a) => a.accountId !== accountId);
|
|
56
|
+
ensureDir(CHANNEL_DIR);
|
|
57
|
+
fs.writeFileSync(accountsFilePath(), JSON.stringify(accounts, null, 2), 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Sync buf (long-poll cursor)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function syncBufFilePath(accountId: string): string {
|
|
65
|
+
return path.join(CHANNEL_DIR, `sync-buf-${accountId}.txt`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadSyncBuf(accountId: string): string {
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(syncBufFilePath(accountId), 'utf-8');
|
|
71
|
+
} catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function saveSyncBuf(accountId: string, buf: string): void {
|
|
77
|
+
ensureDir(CHANNEL_DIR);
|
|
78
|
+
fs.writeFileSync(syncBufFilePath(accountId), buf, 'utf-8');
|
|
79
|
+
}
|