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