cli-wechat-bridge 1.0.5
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.txt +21 -0
- package/README.md +637 -0
- package/bin/_run-entry.mjs +35 -0
- package/bin/wechat-bridge-claude.mjs +5 -0
- package/bin/wechat-bridge-codex.mjs +5 -0
- package/bin/wechat-bridge-opencode.mjs +5 -0
- package/bin/wechat-bridge-shell.mjs +5 -0
- package/bin/wechat-bridge.mjs +5 -0
- package/bin/wechat-check-update.mjs +5 -0
- package/bin/wechat-claude-start.mjs +5 -0
- package/bin/wechat-claude.mjs +5 -0
- package/bin/wechat-codex-start.mjs +5 -0
- package/bin/wechat-codex.mjs +5 -0
- package/bin/wechat-daemon.mjs +5 -0
- package/bin/wechat-opencode-start.mjs +5 -0
- package/bin/wechat-opencode.mjs +5 -0
- package/bin/wechat-setup.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +95 -0
- package/dist/bridge/bridge-adapters.claude.js +829 -0
- package/dist/bridge/bridge-adapters.codex.js +2228 -0
- package/dist/bridge/bridge-adapters.core.js +717 -0
- package/dist/bridge/bridge-adapters.js +26 -0
- package/dist/bridge/bridge-adapters.opencode.js +2129 -0
- package/dist/bridge/bridge-adapters.shared.js +1005 -0
- package/dist/bridge/bridge-adapters.shell.js +363 -0
- package/dist/bridge/bridge-controller.js +48 -0
- package/dist/bridge/bridge-final-reply.js +46 -0
- package/dist/bridge/bridge-process-reaper.js +348 -0
- package/dist/bridge/bridge-state.js +362 -0
- package/dist/bridge/bridge-types.js +1 -0
- package/dist/bridge/bridge-utils.js +1240 -0
- package/dist/bridge/claude-hook.js +82 -0
- package/dist/bridge/claude-hooks.js +267 -0
- package/dist/bridge/wechat-bridge.js +1026 -0
- package/dist/commands/check-update.js +30 -0
- package/dist/companion/codex-panel-link.js +72 -0
- package/dist/companion/codex-panel.js +179 -0
- package/dist/companion/codex-remote-client.js +124 -0
- package/dist/companion/local-companion-link.js +240 -0
- package/dist/companion/local-companion-start.js +420 -0
- package/dist/companion/local-companion.js +424 -0
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- package/dist/media/media-types.js +1 -0
- package/dist/runtime/create-runtime-host.js +12 -0
- package/dist/runtime/legacy-adapter-runtime.js +46 -0
- package/dist/runtime/runtime-types.js +5 -0
- package/dist/utils/version-checker.js +161 -0
- package/dist/wechat/channel-config.js +196 -0
- package/dist/wechat/setup.js +283 -0
- package/dist/wechat/standalone-bot.js +355 -0
- package/dist/wechat/wechat-channel.js +492 -0
- package/dist/wechat/wechat-transport.js +1213 -0
- package/package.json +101 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CLI WeChat Bridge setup.
|
|
4
|
+
*
|
|
5
|
+
* Installed bridge commands run this automatically on first use.
|
|
6
|
+
* To force a relogin manually:
|
|
7
|
+
* wechat-setup
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import readline from "node:readline";
|
|
12
|
+
import { BOT_TYPE, CONTEXT_CACHE_FILE, CREDENTIALS_FILE, DEFAULT_BASE_URL, ensureChannelDataDir, migrateLegacyChannelFiles, SYNC_BUF_FILE, } from "./channel-config.js";
|
|
13
|
+
import { isWechatSyncSessionTimeout } from "./wechat-transport.js";
|
|
14
|
+
const CHANNEL_VERSION = "0.3.0";
|
|
15
|
+
export function loadExistingCredentials() {
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function getWechatLoginRequiredReason(account, options = {}) {
|
|
27
|
+
if (!account) {
|
|
28
|
+
return "No saved WeChat credentials found.";
|
|
29
|
+
}
|
|
30
|
+
if (options.requireUserId && !account.userId) {
|
|
31
|
+
return "Saved WeChat credentials are missing userId.";
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function randomWechatUin() {
|
|
36
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
37
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
38
|
+
}
|
|
39
|
+
export async function getStoredCredentialsInvalidReason(account, options = {}) {
|
|
40
|
+
const baseUrl = account.baseUrl || DEFAULT_BASE_URL;
|
|
41
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
42
|
+
const url = `${base}ilink/bot/getupdates`;
|
|
43
|
+
const body = JSON.stringify({
|
|
44
|
+
get_updates_buf: "",
|
|
45
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
46
|
+
});
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5_000);
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"Content-Length": String(Buffer.byteLength(body, "utf-8")),
|
|
55
|
+
AuthorizationType: "ilink_bot_token",
|
|
56
|
+
Authorization: `Bearer ${account.token}`,
|
|
57
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
58
|
+
},
|
|
59
|
+
body,
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
});
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
if (res.status === 401 || res.status === 403) {
|
|
64
|
+
return "Saved WeChat credentials were rejected by the server.";
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const response = JSON.parse(text);
|
|
70
|
+
if (isWechatSyncSessionTimeout(response)) {
|
|
71
|
+
return "Saved WeChat login has expired.";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
async function fetchQRCode(baseUrl) {
|
|
86
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
87
|
+
const url = `${base}ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
|
|
88
|
+
const res = await fetch(url);
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
throw new Error(`QR fetch failed: ${res.status}`);
|
|
91
|
+
}
|
|
92
|
+
return (await res.json());
|
|
93
|
+
}
|
|
94
|
+
async function pollQRStatus(baseUrl, qrcode) {
|
|
95
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
96
|
+
const url = `${base}ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), 35_000);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(url, {
|
|
101
|
+
headers: { "iLink-App-ClientVersion": "1" },
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new Error(`QR status failed: ${res.status}`);
|
|
107
|
+
}
|
|
108
|
+
return (await res.json());
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
113
|
+
return { status: "wait" };
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function askYesNo(prompt) {
|
|
119
|
+
const rl = readline.createInterface({
|
|
120
|
+
input: process.stdin,
|
|
121
|
+
output: process.stdout,
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const answer = await new Promise((resolve) => {
|
|
125
|
+
rl.question(prompt, resolve);
|
|
126
|
+
});
|
|
127
|
+
return answer.trim().toLowerCase() === "y";
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
rl.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function printQRCode(qrContent, write) {
|
|
134
|
+
try {
|
|
135
|
+
const qrterm = await import("qrcode-terminal");
|
|
136
|
+
await new Promise((resolve) => {
|
|
137
|
+
qrterm.default.generate(qrContent, { small: true }, (qr) => {
|
|
138
|
+
write(`${qr}\n`);
|
|
139
|
+
resolve();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
write(`Open this QR code URL in a browser: ${qrContent}\n\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function saveCredentials(account) {
|
|
148
|
+
ensureChannelDataDir();
|
|
149
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(account, null, 2), "utf-8");
|
|
150
|
+
for (const staleStateFile of [SYNC_BUF_FILE, CONTEXT_CACHE_FILE]) {
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(staleStateFile, { force: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Best effort cleanup.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
fs.chmodSync(CREDENTIALS_FILE, 0o600);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Best effort on Windows.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function printPostLoginHelp(log) {
|
|
166
|
+
log("This WeChat account is now authorized for the bridge.");
|
|
167
|
+
log("");
|
|
168
|
+
log("Start from any project directory with one of:");
|
|
169
|
+
log(" wechat-codex-start");
|
|
170
|
+
log(" wechat-claude-start");
|
|
171
|
+
log(" wechat-opencode-start");
|
|
172
|
+
log(" wechat-bridge-shell");
|
|
173
|
+
log("");
|
|
174
|
+
log("Manual two-terminal mode is also available:");
|
|
175
|
+
log(" wechat-bridge-codex + wechat-codex");
|
|
176
|
+
log(" wechat-bridge-claude + wechat-claude");
|
|
177
|
+
log(" wechat-bridge-opencode + wechat-opencode");
|
|
178
|
+
log("");
|
|
179
|
+
log("Run wechat-setup again any time you need to refresh the login.");
|
|
180
|
+
}
|
|
181
|
+
export async function runWechatLogin(options = {}) {
|
|
182
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
183
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
184
|
+
const write = options.write ?? ((message) => process.stdout.write(message));
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? 480_000;
|
|
186
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
|
|
187
|
+
log("Fetching WeChat login QR code...\n");
|
|
188
|
+
const qrResp = await fetchQRCode(baseUrl);
|
|
189
|
+
await printQRCode(qrResp.qrcode_img_content, write);
|
|
190
|
+
log("Scan the QR code above with WeChat, then confirm the login on your phone.\n");
|
|
191
|
+
const deadline = Date.now() + timeoutMs;
|
|
192
|
+
let scannedPrinted = false;
|
|
193
|
+
while (Date.now() < deadline) {
|
|
194
|
+
const status = await pollQRStatus(baseUrl, qrResp.qrcode);
|
|
195
|
+
switch (status.status) {
|
|
196
|
+
case "wait":
|
|
197
|
+
write(".");
|
|
198
|
+
break;
|
|
199
|
+
case "scaned":
|
|
200
|
+
if (!scannedPrinted) {
|
|
201
|
+
log("\nQR code scanned. Confirm the login in WeChat...");
|
|
202
|
+
scannedPrinted = true;
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
case "expired":
|
|
206
|
+
throw new Error("The QR code expired. Run setup again.");
|
|
207
|
+
case "confirmed": {
|
|
208
|
+
if (!status.ilink_bot_id || !status.bot_token) {
|
|
209
|
+
throw new Error("Login failed: missing bot credentials from server.");
|
|
210
|
+
}
|
|
211
|
+
if (options.requireUserId && !status.ilink_user_id) {
|
|
212
|
+
throw new Error("Login failed: missing WeChat userId from server.");
|
|
213
|
+
}
|
|
214
|
+
const account = {
|
|
215
|
+
token: status.bot_token,
|
|
216
|
+
baseUrl: status.baseurl || baseUrl,
|
|
217
|
+
accountId: status.ilink_bot_id,
|
|
218
|
+
userId: status.ilink_user_id,
|
|
219
|
+
savedAt: new Date().toISOString(),
|
|
220
|
+
};
|
|
221
|
+
saveCredentials(account);
|
|
222
|
+
log("\nWeChat login completed.");
|
|
223
|
+
log(`Account ID: ${account.accountId}`);
|
|
224
|
+
log(`User ID: ${account.userId ?? "(unknown)"}`);
|
|
225
|
+
log(`Credentials saved to: ${CREDENTIALS_FILE}`);
|
|
226
|
+
log("");
|
|
227
|
+
return account;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
231
|
+
}
|
|
232
|
+
throw new Error("Login timed out. Run setup again.");
|
|
233
|
+
}
|
|
234
|
+
export async function ensureWechatCredentials(options = {}) {
|
|
235
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
236
|
+
migrateLegacyChannelFiles(log);
|
|
237
|
+
const existing = loadExistingCredentials();
|
|
238
|
+
const loginReason = getWechatLoginRequiredReason(existing, {
|
|
239
|
+
requireUserId: options.requireUserId,
|
|
240
|
+
});
|
|
241
|
+
if (!loginReason) {
|
|
242
|
+
const account = existing;
|
|
243
|
+
if (options.validateExisting) {
|
|
244
|
+
const invalidReason = await getStoredCredentialsInvalidReason(account, {
|
|
245
|
+
timeoutMs: options.validationTimeoutMs,
|
|
246
|
+
});
|
|
247
|
+
if (invalidReason) {
|
|
248
|
+
log(`${invalidReason} Starting WeChat login...`);
|
|
249
|
+
const login = options.login ?? (() => runWechatLogin(options));
|
|
250
|
+
return login();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return account;
|
|
254
|
+
}
|
|
255
|
+
log(`${loginReason} Starting WeChat login...`);
|
|
256
|
+
const login = options.login ?? (() => runWechatLogin(options));
|
|
257
|
+
return login();
|
|
258
|
+
}
|
|
259
|
+
async function main() {
|
|
260
|
+
migrateLegacyChannelFiles((message) => console.log(message));
|
|
261
|
+
const existing = loadExistingCredentials();
|
|
262
|
+
if (existing) {
|
|
263
|
+
console.log(`Found saved account: ${existing.accountId}`);
|
|
264
|
+
console.log(`Saved at: ${existing.savedAt}`);
|
|
265
|
+
console.log(`Credentials file: ${CREDENTIALS_FILE}`);
|
|
266
|
+
console.log();
|
|
267
|
+
const shouldRelogin = await askYesNo("Log in again? (y/N) ");
|
|
268
|
+
if (!shouldRelogin) {
|
|
269
|
+
console.log("Keeping existing credentials.");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await runWechatLogin({ requireUserId: true });
|
|
274
|
+
printPostLoginHelp((message) => console.log(message));
|
|
275
|
+
}
|
|
276
|
+
const isDirectRun = Boolean(import.meta.main);
|
|
277
|
+
if (isDirectRun) {
|
|
278
|
+
main().catch((err) => {
|
|
279
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
280
|
+
console.error(`Error: ${message}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Standalone WeChat + Claude bot.
|
|
4
|
+
*
|
|
5
|
+
* This script directly connects WeChat ClawBot to Claude API,
|
|
6
|
+
* bypassing the MCP channel protocol.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun run src/wechat/standalone-bot.ts
|
|
10
|
+
*/
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
15
|
+
import { CHANNEL_DATA_DIR, CREDENTIALS_FILE, migrateLegacyChannelFiles, SYNC_BUF_FILE, } from "./channel-config.js";
|
|
16
|
+
// Constants
|
|
17
|
+
const LOG_DIR = path.join(CHANNEL_DATA_DIR, "logs");
|
|
18
|
+
const BOT_TYPE = "3";
|
|
19
|
+
const CHANNEL_VERSION = "0.1.0";
|
|
20
|
+
const LONG_POLL_TIMEOUT_MS = 35_000;
|
|
21
|
+
const MSG_TYPE_USER = 1;
|
|
22
|
+
const MSG_TYPE_BOT = 2;
|
|
23
|
+
const MSG_ITEM_TEXT = 1;
|
|
24
|
+
const MSG_ITEM_VOICE = 3;
|
|
25
|
+
const MSG_STATE_FINISH = 2;
|
|
26
|
+
// Conversation history per user
|
|
27
|
+
const conversationHistory = new Map();
|
|
28
|
+
const MAX_HISTORY = 10;
|
|
29
|
+
// Logging
|
|
30
|
+
function log(message) {
|
|
31
|
+
const timestamp = new Date().toISOString();
|
|
32
|
+
console.log(`[${timestamp}] ${message}`);
|
|
33
|
+
}
|
|
34
|
+
function logError(message) {
|
|
35
|
+
const timestamp = new Date().toISOString();
|
|
36
|
+
console.error(`[${timestamp}] ERROR: ${message}`);
|
|
37
|
+
}
|
|
38
|
+
// Load WeChat credentials
|
|
39
|
+
function loadCredentials() {
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
42
|
+
logError(`Credentials file not found: ${CREDENTIALS_FILE}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const content = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
46
|
+
return JSON.parse(content);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
logError(`Failed to read credentials: ${String(err)}`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// WeChat API utilities
|
|
54
|
+
function randomWechatUin() {
|
|
55
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
56
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
57
|
+
}
|
|
58
|
+
function buildHeaders(token, body) {
|
|
59
|
+
const headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
AuthorizationType: "ilink_bot_token",
|
|
62
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
63
|
+
};
|
|
64
|
+
if (body) {
|
|
65
|
+
headers["Content-Length"] = String(Buffer.byteLength(body, "utf-8"));
|
|
66
|
+
}
|
|
67
|
+
if (token?.trim()) {
|
|
68
|
+
headers.Authorization = `Bearer ${token.trim()}`;
|
|
69
|
+
}
|
|
70
|
+
return headers;
|
|
71
|
+
}
|
|
72
|
+
async function apiFetch(params) {
|
|
73
|
+
const base = params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`;
|
|
74
|
+
const url = new URL(params.endpoint, base).toString();
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: buildHeaders(params.token, params.body),
|
|
81
|
+
body: params.body,
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
88
|
+
}
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function getUpdates(baseUrl, token, getUpdatesBuf) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await apiFetch({
|
|
99
|
+
baseUrl,
|
|
100
|
+
endpoint: "ilink/bot/getupdates",
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
get_updates_buf: getUpdatesBuf,
|
|
103
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
104
|
+
}),
|
|
105
|
+
token,
|
|
106
|
+
timeoutMs: LONG_POLL_TIMEOUT_MS,
|
|
107
|
+
});
|
|
108
|
+
return JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
112
|
+
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function extractTextFromMessage(msg) {
|
|
118
|
+
if (!msg.item_list?.length) {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
const lines = [];
|
|
122
|
+
for (const item of msg.item_list) {
|
|
123
|
+
if (item.type === MSG_ITEM_TEXT) {
|
|
124
|
+
const text = item.text_item?.text?.trim();
|
|
125
|
+
if (text) {
|
|
126
|
+
lines.push(text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (item.type === MSG_ITEM_VOICE) {
|
|
130
|
+
const transcript = item.voice_item?.text?.trim();
|
|
131
|
+
if (transcript) {
|
|
132
|
+
lines.push(transcript);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n").trim();
|
|
137
|
+
}
|
|
138
|
+
function generateClientId() {
|
|
139
|
+
return `claude-wechat-bot:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
140
|
+
}
|
|
141
|
+
async function sendTextMessage(baseUrl, token, to, text, contextToken) {
|
|
142
|
+
const trimmedText = text.trim();
|
|
143
|
+
if (!trimmedText) {
|
|
144
|
+
throw new Error("Reply text cannot be empty.");
|
|
145
|
+
}
|
|
146
|
+
const clientId = generateClientId();
|
|
147
|
+
await apiFetch({
|
|
148
|
+
baseUrl,
|
|
149
|
+
endpoint: "ilink/bot/sendmessage",
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
msg: {
|
|
152
|
+
from_user_id: "",
|
|
153
|
+
to_user_id: to,
|
|
154
|
+
client_id: clientId,
|
|
155
|
+
message_type: MSG_TYPE_BOT,
|
|
156
|
+
message_state: MSG_STATE_FINISH,
|
|
157
|
+
item_list: [{ type: MSG_ITEM_TEXT, text_item: { text: trimmedText } }],
|
|
158
|
+
context_token: contextToken,
|
|
159
|
+
},
|
|
160
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
161
|
+
}),
|
|
162
|
+
token,
|
|
163
|
+
timeoutMs: 15_000,
|
|
164
|
+
});
|
|
165
|
+
return clientId;
|
|
166
|
+
}
|
|
167
|
+
// Message deduplication
|
|
168
|
+
const recentMessageKeys = new Set();
|
|
169
|
+
const recentMessageOrder = [];
|
|
170
|
+
const RECENT_MESSAGE_CACHE_SIZE = 500;
|
|
171
|
+
function buildMessageKey(msg) {
|
|
172
|
+
return [
|
|
173
|
+
msg.from_user_id ?? "",
|
|
174
|
+
msg.client_id ?? "",
|
|
175
|
+
String(msg.create_time_ms ?? ""),
|
|
176
|
+
msg.context_token ?? "",
|
|
177
|
+
].join("|");
|
|
178
|
+
}
|
|
179
|
+
function rememberMessage(key) {
|
|
180
|
+
if (!key || recentMessageKeys.has(key)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
recentMessageKeys.add(key);
|
|
184
|
+
recentMessageOrder.push(key);
|
|
185
|
+
while (recentMessageOrder.length > RECENT_MESSAGE_CACHE_SIZE) {
|
|
186
|
+
const oldest = recentMessageOrder.shift();
|
|
187
|
+
if (oldest) {
|
|
188
|
+
recentMessageKeys.delete(oldest);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
// Claude API integration
|
|
194
|
+
let anthropic = null;
|
|
195
|
+
function initClaude() {
|
|
196
|
+
// 使用与 Claude Code 相同的代理配置
|
|
197
|
+
const baseURL = process.env.ANTHROPIC_BASE_URL || "http://127.0.0.1:15721";
|
|
198
|
+
anthropic = new Anthropic({
|
|
199
|
+
apiKey: "dummy-key", // 代理不需要真实的 API key
|
|
200
|
+
baseURL: baseURL,
|
|
201
|
+
dangerouslyAllowBrowser: true, // 允许在非标准环境下使用
|
|
202
|
+
});
|
|
203
|
+
log(`Claude API initialized (via proxy: ${baseURL})`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
async function getClaudeResponse(userMessage, userId) {
|
|
207
|
+
if (!anthropic) {
|
|
208
|
+
throw new Error("Claude API not initialized");
|
|
209
|
+
}
|
|
210
|
+
// Get or create conversation history
|
|
211
|
+
let history = conversationHistory.get(userId) || [];
|
|
212
|
+
// Add user message to history
|
|
213
|
+
history.push({ role: "user", content: userMessage });
|
|
214
|
+
// Keep only recent messages
|
|
215
|
+
if (history.length > MAX_HISTORY) {
|
|
216
|
+
history = history.slice(-MAX_HISTORY);
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const msg = await anthropic.messages.create({
|
|
220
|
+
model: "claude-sonnet-4-20250514",
|
|
221
|
+
max_tokens: 1024,
|
|
222
|
+
system: "你是一个有帮助的AI助手。请用简洁、友好的方式回复,适合微信聊天场景。",
|
|
223
|
+
messages: history,
|
|
224
|
+
});
|
|
225
|
+
// Extract the response text
|
|
226
|
+
let responseText = "";
|
|
227
|
+
for (const block of msg.content) {
|
|
228
|
+
if (block.type === "text") {
|
|
229
|
+
responseText += block.text;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Add assistant response to history
|
|
233
|
+
history.push({ role: "assistant", content: responseText });
|
|
234
|
+
conversationHistory.set(userId, history);
|
|
235
|
+
return responseText;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
logError(`Claude API error: ${String(err)}`);
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Main polling loop
|
|
243
|
+
async function startPolling(account) {
|
|
244
|
+
const { baseUrl, token } = account;
|
|
245
|
+
let getUpdatesBuf = "";
|
|
246
|
+
// Load sync state
|
|
247
|
+
try {
|
|
248
|
+
if (fs.existsSync(SYNC_BUF_FILE)) {
|
|
249
|
+
getUpdatesBuf = fs.readFileSync(SYNC_BUF_FILE, "utf-8");
|
|
250
|
+
if (getUpdatesBuf) {
|
|
251
|
+
log(`Recovered sync state from ${SYNC_BUF_FILE}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
logError(`Failed to load sync state: ${String(err)}`);
|
|
257
|
+
}
|
|
258
|
+
log("Starting WeChat message polling loop...");
|
|
259
|
+
log("Press Ctrl+C to stop");
|
|
260
|
+
const contextTokenCache = new Map();
|
|
261
|
+
while (true) {
|
|
262
|
+
try {
|
|
263
|
+
const resp = await getUpdates(baseUrl, token, getUpdatesBuf);
|
|
264
|
+
const isError = (resp.ret !== undefined && resp.ret !== 0) ||
|
|
265
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
266
|
+
if (isError) {
|
|
267
|
+
logError(`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""}`);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// Save sync state
|
|
272
|
+
if (resp.get_updates_buf) {
|
|
273
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
274
|
+
try {
|
|
275
|
+
fs.writeFileSync(SYNC_BUF_FILE, getUpdatesBuf, "utf-8");
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
logError(`Failed to persist sync state: ${String(err)}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Process messages
|
|
282
|
+
for (const msg of resp.msgs ?? []) {
|
|
283
|
+
if (msg.message_type !== MSG_TYPE_USER) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const text = extractTextFromMessage(msg);
|
|
287
|
+
if (!text) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const senderId = msg.from_user_id ?? "unknown";
|
|
291
|
+
const messageKey = buildMessageKey(msg);
|
|
292
|
+
if (!rememberMessage(messageKey)) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Cache context token for replies
|
|
296
|
+
if (msg.context_token) {
|
|
297
|
+
contextTokenCache.set(senderId, msg.context_token);
|
|
298
|
+
}
|
|
299
|
+
log(`📨 收到消息 from ${senderId}: ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`);
|
|
300
|
+
// Get response from Claude
|
|
301
|
+
try {
|
|
302
|
+
const response = await getClaudeResponse(text, senderId);
|
|
303
|
+
log(`📤 发送回复 to ${senderId}: ${response.slice(0, 50)}${response.length > 50 ? "..." : ""}`);
|
|
304
|
+
const contextToken = contextTokenCache.get(senderId) || "";
|
|
305
|
+
await sendTextMessage(baseUrl, token, senderId, response, contextToken);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
logError(`Failed to get/send response: ${String(err)}`);
|
|
309
|
+
// Send error message to user
|
|
310
|
+
try {
|
|
311
|
+
const contextToken = contextTokenCache.get(senderId) || "";
|
|
312
|
+
await sendTextMessage(baseUrl, token, senderId, "抱歉,我遇到了一些问题,请稍后再试。", contextToken);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Ignore
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
logError(`Polling error: ${String(err)}`);
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Main function
|
|
327
|
+
async function main() {
|
|
328
|
+
log("=== WeChat + Claude 独立机器人 ===");
|
|
329
|
+
log("");
|
|
330
|
+
migrateLegacyChannelFiles(log);
|
|
331
|
+
// Load WeChat credentials
|
|
332
|
+
log(`正在加载微信凭据: ${CREDENTIALS_FILE}`);
|
|
333
|
+
const account = loadCredentials();
|
|
334
|
+
if (!account) {
|
|
335
|
+
logError("无法加载微信凭据,请先运行: bun run setup");
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
log(`✓ 微信账号: ${account.accountId}`);
|
|
339
|
+
// Initialize Claude API
|
|
340
|
+
log("");
|
|
341
|
+
log("正在初始化 Claude API...");
|
|
342
|
+
if (!initClaude()) {
|
|
343
|
+
logError("Claude API 初始化失败");
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
log("");
|
|
347
|
+
log("✓ 所有初始化完成!");
|
|
348
|
+
log("");
|
|
349
|
+
// Start polling
|
|
350
|
+
await startPolling(account);
|
|
351
|
+
}
|
|
352
|
+
main().catch((err) => {
|
|
353
|
+
logError(`Fatal: ${String(err)}`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
});
|