@yvhitxcel/opencode-remote 0.16.3 → 0.18.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.
@@ -53,7 +53,14 @@ async function apiFetch(params) {
53
53
  }
54
54
  catch (err) {
55
55
  clearTimeout(t);
56
- console.error(`[apiFetch] ${params.label} error:`, err.message);
56
+ const details = {
57
+ url: url.toString(),
58
+ label: params.label,
59
+ message: err.message,
60
+ cause: err.cause ? (err.cause.message || String(err.cause)) : undefined,
61
+ code: err.code,
62
+ };
63
+ console.error(`[apiFetch] ${params.label} error:`, JSON.stringify(details));
57
64
  throw err;
58
65
  }
59
66
  }
@@ -61,7 +68,7 @@ async function apiFetch(params) {
61
68
  // Login APIs
62
69
  // ---------------------------------------------------------------------------
63
70
  /**
64
- * Fetch QR code for login
71
+ * Fetch QR code for login (admin auth)
65
72
  */
66
73
  export async function fetchQRCode(baseUrl = DEFAULT_BASE_URL, botType = '3') {
67
74
  const base = ensureTrailingSlash(baseUrl);
@@ -122,26 +129,47 @@ export async function getUpdates(params) {
122
129
  }
123
130
  }
124
131
  /**
125
- * Send a message
132
+ * Send a message with rate limit retry
126
133
  */
127
134
  export async function sendMessage(params) {
128
- const rawText = await apiFetch({
129
- baseUrl: params.baseUrl,
130
- endpoint: 'ilink/bot/sendmessage',
131
- body: JSON.stringify({
132
- ...params.body,
133
- base_info: { channel_version: '1.0.0' },
134
- }),
135
- token: params.token,
136
- timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
137
- label: 'sendMessage',
138
- });
139
- try {
140
- JSON.parse(rawText);
141
- }
142
- catch (e) {
143
- console.debug('[api] Non-JSON response:', e.message);
135
+ const MAX_ATTEMPTS = 3;
136
+ let lastErr;
137
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
138
+ try {
139
+ const rawText = await apiFetch({
140
+ baseUrl: params.baseUrl,
141
+ endpoint: 'ilink/bot/sendmessage',
142
+ body: JSON.stringify({
143
+ ...params.body,
144
+ base_info: { channel_version: '1.0.0' },
145
+ }),
146
+ token: params.token,
147
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
148
+ label: 'sendMessage',
149
+ });
150
+ // 微信返回限流错误码时,rawText errcode,重试
151
+ try {
152
+ const j = JSON.parse(rawText);
153
+ if (j && typeof j.errcode === 'number' && (j.errcode === -1001 || j.errcode === -1002 || j.errcode === 45009 || j.errcode === 45047)) {
154
+ const backoff = 1000 * attempt;
155
+ console.warn(`[sendMessage] rate-limited (errcode=${j.errcode}), retry in ${backoff}ms (${attempt}/${MAX_ATTEMPTS})`);
156
+ await new Promise(r => setTimeout(r, backoff));
157
+ continue;
158
+ }
159
+ } catch (e) { console.debug('[sendMessage] parse error:', e.message); }
160
+ return;
161
+ } catch (err) {
162
+ lastErr = err;
163
+ if (attempt < MAX_ATTEMPTS && /AbortError|fetch failed|ECONNRESET/i.test(err.message || '')) {
164
+ const backoff = 1000 * attempt;
165
+ console.warn(`[sendMessage] transient error, retry in ${backoff}ms: ${err.message}`);
166
+ await new Promise(r => setTimeout(r, backoff));
167
+ continue;
168
+ }
169
+ throw err;
170
+ }
144
171
  }
172
+ throw lastErr;
145
173
  }
146
174
  /**
147
175
  * Get bot config (includes typing_ticket)
@@ -1,7 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, chmodSync, unlinkSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, chmodSync, unlinkSync, readdirSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
- import { initSessionManager, loadSessionMapping, saveSessionMapping } from '../core/session.js';
5
4
  import { initOpenCode, initFetchConfig } from '../opencode/client.js';
6
5
  import { getAuthStatus } from '../core/auth.js';
7
6
  import { registry } from '../core/registry.js';
@@ -9,15 +8,32 @@ import { fetchQRCode, pollQRStatus, getUpdates } from './api.js';
9
8
  import { DEFAULT_BASE_URL } from './types.js';
10
9
  import { createWeixinAdapter } from './adapter.js';
11
10
  import { handleMessage } from './handler.js';
11
+ import { userAdapterMap } from './user-adapter-map.js';
12
+ import { initState } from '../core/state.js';
13
+ import { initLogger, cleanOldLogs, logger } from '../core/log.js';
14
+ import { LRUSessionMap } from '../core/lru.js';
15
+ import { encryptCredential, decryptCredential } from '../core/crypto.js';
12
16
  export { COMMAND_ALIASES, detectCommand } from '../core/router.js';
13
17
 
18
+ let _initialized = false;
19
+ function initBot() {
20
+ if (_initialized) return;
21
+ _initialized = true;
22
+ initLogger();
23
+ cleanOldLogs();
24
+ initState();
25
+ logger.info('Bot starting', { ts: new Date().toISOString() });
26
+ }
27
+
14
28
  const CONFIG_DIR = join(homedir(), '.opencode-remote');
15
29
  const WEIXIN_DIR = join(CONFIG_DIR, 'weixin');
30
+ const CREDENTIALS_DIR = join(WEIXIN_DIR, 'credentials');
16
31
  const INSTANCE_ID = process.env.OPENCODE_INSTANCE_ID || 'default';
17
32
  const CREDENTIALS_FILE = INSTANCE_ID === 'default'
18
33
  ? join(WEIXIN_DIR, 'credentials.json')
19
34
  : join(WEIXIN_DIR, `credentials-${INSTANCE_ID}.json`);
20
- const RESTART_NOTIFY_FILE = join(WEIXIN_DIR, 'restart-notify.json');
35
+
36
+ const botInstances = [];
21
37
 
22
38
  export async function loginWithQR(baseUrl = DEFAULT_BASE_URL, onQRCode) {
23
39
  console.log('Starting Weixin login...');
@@ -48,21 +64,112 @@ export async function loginWithQR(baseUrl = DEFAULT_BASE_URL, onQRCode) {
48
64
  } catch (e) { console.error('Login error:', e); return null; }
49
65
  }
50
66
 
51
- function ensureDirs() { if (!existsSync(WEIXIN_DIR)) mkdirSync(WEIXIN_DIR, { recursive: true }); }
52
- export function loadWeixinCredentials() {
67
+ function ensureDirs() {
68
+ if (!existsSync(WEIXIN_DIR)) mkdirSync(WEIXIN_DIR, { recursive: true });
69
+ if (!existsSync(CREDENTIALS_DIR)) mkdirSync(CREDENTIALS_DIR, { recursive: true });
70
+ }
71
+
72
+ export function loadAllCredentials() {
53
73
  ensureDirs();
54
- if (!existsSync(CREDENTIALS_FILE)) return null;
55
- try { return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8')); } catch (e) { console.debug('[credentials] Failed to parse:', e.message); return null; }
74
+ if (existsSync(CREDENTIALS_DIR)) {
75
+ const files = readdirSync(CREDENTIALS_DIR).filter(f => f.endsWith('.json'));
76
+ if (files.length > 0) {
77
+ return files.map(f => {
78
+ try {
79
+ const raw = readFileSync(join(CREDENTIALS_DIR, f), 'utf-8');
80
+ const obj = JSON.parse(raw);
81
+ // 检测是否加密信封 → 解密
82
+ if (obj && obj.v === 1 && obj.enc) {
83
+ const decrypted = decryptCredential(obj.enc);
84
+ if (decrypted) return JSON.parse(decrypted);
85
+ }
86
+ return obj;
87
+ }
88
+ catch (e) { console.debug('[credentials] Failed to parse:', f, e.message); return null; }
89
+ }).filter(Boolean);
90
+ }
91
+ }
92
+ if (existsSync(CREDENTIALS_FILE)) {
93
+ try { return [JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'))]; } catch (e) { console.debug('[credentials] Failed to parse legacy:', e.message); }
94
+ }
95
+ return [];
56
96
  }
97
+
98
+ export function loadWeixinCredentials() {
99
+ const all = loadAllCredentials();
100
+ return all.length > 0 ? all[0] : null;
101
+ }
102
+
57
103
  export function saveWeixinCredentials(creds) {
58
104
  ensureDirs();
59
- writeFileSync(CREDENTIALS_FILE, JSON.stringify({ ...creds, savedAt: new Date().toISOString() }, null, 2), 'utf-8');
60
- try { const s = statSync(CREDENTIALS_FILE); chmodSync(CREDENTIALS_FILE, (s.mode & 0o777) | 0o600); } catch (e) { console.warn('[credentials] chmod failed:', e.message); }
105
+ const filePath = join(CREDENTIALS_DIR, `credentials-${creds.accountId}.json`);
106
+ const plain = JSON.stringify({ ...creds, savedAt: new Date().toISOString() }, null, 2);
107
+ const enc = encryptCredential(plain);
108
+ const envelope = { v: 1, enc, savedAt: new Date().toISOString() };
109
+ writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf-8');
110
+ try { const s = statSync(filePath); chmodSync(filePath, (s.mode & 0o777) | 0o600); } catch (e) { console.warn('[credentials] chmod failed:', e.message); }
111
+ }
112
+
113
+ export function saveCredential(creds) {
114
+ saveWeixinCredentials(creds);
61
115
  }
62
116
 
63
117
  let _restartCallback = null;
64
118
  function setRestartCallback(fn) { _restartCallback = fn; }
119
+
120
+ async function runPollingLoop(adapter, baseUrl, token, openCodeSessions, signal) {
121
+ let buf = '';
122
+ let retryCount = 0;
123
+ while (!signal.aborted) {
124
+ try {
125
+ const resp = await getUpdates({ baseUrl, token, get_updates_buf: buf });
126
+ if (signal.aborted) break;
127
+ if (resp.get_updates_buf) buf = resp.get_updates_buf;
128
+ for (const msg of (resp.msgs || [])) {
129
+ if (msg.message_type !== 1) continue;
130
+ const textItem = msg.item_list?.find((i) => i.type === 1);
131
+ const text = textItem?.text_item?.text;
132
+ const fromUserId = msg.from_user_id;
133
+ if (!fromUserId || !text) continue;
134
+ userAdapterMap.set(fromUserId, adapter);
135
+ const messageId = msg.message_id?.toString();
136
+ if (adapter.isDuplicate(messageId, `${fromUserId}:${text}`)) continue;
137
+ if (msg.context_token) adapter.contextTokens.set(fromUserId, { value: msg.context_token, _ts: Date.now() });
138
+ try { await handleMessage(adapter, { platform: 'weixin', threadId: fromUserId, userId: fromUserId, messageId }, text, openCodeSessions); } catch (e) { console.error('Handle error:', e); }
139
+ }
140
+ } catch (e) {
141
+ if (signal.aborted) break;
142
+ const errMsg = e.message || '';
143
+ const isConnReset = errMsg.includes('ECONNRESET') || errMsg.includes('fetch failed');
144
+ if (isConnReset) {
145
+ retryCount++;
146
+ const delay = Math.min(2000 * retryCount, 15000);
147
+ console.error(`[bot] Connection error (${retryCount}), retry in ${delay}ms...`);
148
+ await new Promise(r => setTimeout(r, delay));
149
+ } else {
150
+ console.error('Polling error:', e);
151
+ await new Promise(r => setTimeout(r, 2000));
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ export function addBotInstance(creds, openCodeSessions) {
158
+ const baseUrl = creds.baseUrl || DEFAULT_BASE_URL;
159
+ const token = creds.token;
160
+ const botId = creds.accountId;
161
+ const adapter = createWeixinAdapter(baseUrl, token, botId);
162
+ const abortController = new AbortController();
163
+ const instance = { adapter, abortController, creds };
164
+ botInstances.push(instance);
165
+ runPollingLoop(adapter, baseUrl, token, openCodeSessions, abortController.signal).catch(e => console.error('[bot] Polling loop ended:', e));
166
+ return instance;
167
+ }
168
+
65
169
  export async function startWeixinBot(botConfig, restartFn) {
170
+ // 仅在子进程启动时初始化日志和状态 (不在父进程 import 时)
171
+ initBot();
172
+ process.on('unhandledRejection', (reason) => { console.error('[bot] Unhandled Rejection:', reason); });
66
173
  if (restartFn) _restartCallback = restartFn;
67
174
  console.log('');
68
175
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
@@ -72,78 +179,78 @@ export async function startWeixinBot(botConfig, restartFn) {
72
179
 
73
180
  await registry.loadBuiltInPlugins();
74
181
 
75
- try { await initFetchConfig(); } catch (e) { console.warn('⚠️ Fetch config failed:', e); }
76
- let credentials = loadWeixinCredentials();
77
- if (!credentials) {
182
+ let credentialsList = loadAllCredentials();
183
+ if (credentialsList.length === 0) {
78
184
  console.log('No saved credentials. Starting login...');
79
- credentials = await loginWithQR(botConfig.weixinBaseUrl || DEFAULT_BASE_URL);
80
- if (!credentials) { console.error('Login failed'); process.exit(1); }
185
+ const creds = await loginWithQR(botConfig.weixinBaseUrl || DEFAULT_BASE_URL);
186
+ if (!creds) { console.error('Login failed'); process.exit(1); }
187
+ credentialsList = [creds];
81
188
  }
82
- console.log(`Using account: ${credentials.accountId}`);
83
- const baseUrl = credentials.baseUrl || DEFAULT_BASE_URL;
84
- const token = credentials.token;
85
- const botId = credentials.accountId;
86
- initSessionManager(botConfig);
87
- const openCodeSessions = new Map();
88
- const adapter = createWeixinAdapter(baseUrl, token, botId);
89
- try { await initOpenCode(); console.log('OpenCode ready'); } catch (e) { console.error('Failed to init OpenCode:', e); }
90
-
189
+ const firstCreds = credentialsList[0];
190
+ console.log(`Using account: ${firstCreds.accountId}${credentialsList.length > 1 ? ` (+${credentialsList.length - 1} more)` : ''}`);
191
+ const openCodeSessions = new LRUSessionMap({ maxSize: 100, ttlMs: 30 * 60 * 1000, name: 'opencode-sessions' });
192
+ // 定期清理过期 session (每 5 分钟)
193
+ setInterval(() => openCodeSessions.cleanup(), 5 * 60 * 1000).unref();
194
+
195
+ let opencodeServer = null;
91
196
  try {
92
197
  const opencode = await initOpenCode();
93
198
  if (opencode) {
199
+ opencodeServer = opencode.server;
200
+ globalThis.__opencodeServer = opencode.server;
201
+ console.log('OpenCode ready');
94
202
  const result = await opencode.client.session.list();
95
203
  if (!result.error && result.data && result.data.length > 0) {
96
204
  const sorted = result.data.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
97
205
  const latest = sorted[0];
98
206
  console.log(`Latest OpenCode session: ${latest.title || 'Untitled'} (${latest.id.slice(0, 8)}...)`);
99
- globalThis.__latestOpenCodeSession = { id: latest.id, directory: latest.directory };
100
207
  if (latest.directory) {
101
- console.log(`Project directory: ${latest.directory}`);
102
- globalThis.__autoProjectDir = latest.directory;
208
+ // resume 的目录可能指向 bot 自身而非 openchat 项目
209
+ // 如果 resume 目录没 lab.mjs,尝试 fallback 到 openchat 项目
210
+ const projectDir = existsSync(`${latest.directory}/bridge/bin/lab.mjs`) ? latest.directory : (existsSync('F:\\openchat\\bridge\\bin\\lab.mjs') ? 'F:\\openchat' : latest.directory);
211
+ console.log(`Project directory: ${projectDir}`);
212
+ globalThis.__autoProjectDir = projectDir;
103
213
  }
104
214
  }
105
215
  }
106
- } catch (e) { console.warn('⚠️ Auto-resume failed:', e.message); }
107
-
216
+ } catch (e) { console.error('Failed to init OpenCode:', e); }
217
+
108
218
  if (!getAuthStatus().weixin) {
109
219
  console.log('\n🔒 Bot not secured! First user to send /start becomes owner.\n');
110
220
  }
111
-
112
- try {
113
- if (existsSync(RESTART_NOTIFY_FILE)) {
114
- const data = JSON.parse(readFileSync(RESTART_NOTIFY_FILE, 'utf8'));
115
- if (data.threadId && Date.now() - data.time < 60000) {
116
- await adapter.reply(data.threadId, '✅ Bot 重启完成!');
117
- }
118
- unlinkSync(RESTART_NOTIFY_FILE);
119
- }
120
- } catch (e) { console.warn('[restart-notify] Failed to read restart file:', e.message); }
121
-
122
- let running = true;
221
+
222
+ for (const creds of credentialsList) {
223
+ console.log(`Starting bot for account: ${creds.accountId}`);
224
+ addBotInstance(creds, openCodeSessions);
225
+ }
226
+
227
+ // IPC 心跳:每 30s 通知父进程还活着
228
+ const hbTimer = setInterval(() => { try { process.send?.({ type: 'heartbeat', ts: Date.now() }); } catch {} }, 30_000);
229
+ if (hbTimer.unref) hbTimer.unref();
230
+
123
231
  let shouldRestart = false;
124
232
  const shutdown = (restart = false) => {
125
233
  console.log(restart ? '\nRestarting...' : '\nShutting down...');
126
- saveSessionMapping();
127
- running = false;
128
234
  shouldRestart = restart;
129
- for (const [, s] of openCodeSessions.entries()) { try { s.server?.shutdown?.(); } catch (e) { console.warn('[shutdown] Server shutdown error:', e.message); } }
235
+ for (const instance of botInstances) {
236
+ try { instance.abortController.abort(); } catch (e) { }
237
+ }
238
+ // 关掉 opencode server 进程,防重启后端口冲突
239
+ try { globalThis.__opencodeServer?.kill?.(); } catch (e) { console.warn('[shutdown] Server kill error:', e.message); }
130
240
  openCodeSessions.clear();
131
241
  };
132
-
133
- globalThis.__weixinBotShutdown = (restart = false) => shutdown(restart);
134
- globalThis.__weixinBotRunning = () => running;
135
242
 
136
- let buf = '';
137
- let retryCount = 0;
138
- console.log('Polling for messages...');
243
+ globalThis.__weixinBotShutdown = (restart = false) => shutdown(restart);
244
+ globalThis.__weixinBotRunning = () => botInstances.some(i => !i.abortController.signal.aborted);
139
245
 
140
246
  if (process.env.OPENCODE_RESTART === '1') {
141
247
  try {
248
+ const firstAdapter = botInstances[0]?.adapter;
142
249
  const restartInfoPath = join(process.env.HOME || process.cwd(), '.opencode-remote', '.restart_user.json');
143
250
  if (existsSync(restartInfoPath)) {
144
251
  const restartInfo = JSON.parse(readFileSync(restartInfoPath, 'utf8'));
145
- if (Date.now() - restartInfo.time < 60000) {
146
- await adapter.reply(restartInfo.threadId, '✅ Bot 重启完成!');
252
+ if (Date.now() - restartInfo.time < 60000 && firstAdapter) {
253
+ await firstAdapter.reply(restartInfo.threadId, '✅ Bot 重启完成!');
147
254
  console.log('Sent restart notification to user');
148
255
  }
149
256
  unlinkSync(restartInfoPath);
@@ -153,38 +260,20 @@ export async function startWeixinBot(botConfig, restartFn) {
153
260
  }
154
261
  }
155
262
 
156
- while (running) {
157
- try {
158
- const resp = await getUpdates({ baseUrl, token, get_updates_buf: buf });
159
- if (!running) break;
160
- if (resp.get_updates_buf) buf = resp.get_updates_buf;
161
- for (const msg of (resp.msgs || [])) {
162
- if (msg.message_type !== 1) continue;
163
- const textItem = msg.item_list?.find((i) => i.type === 1);
164
- const text = textItem?.text_item?.text;
165
- const fromUserId = msg.from_user_id;
166
- if (!fromUserId || !text) continue;
167
- const messageId = msg.message_id?.toString();
168
- if (adapter.isDuplicate(messageId)) continue;
169
- if (msg.context_token) adapter.contextTokens.set(fromUserId, msg.context_token);
170
- handleMessage(adapter, { platform: 'weixin', threadId: fromUserId, userId: fromUserId, messageId }, text, openCodeSessions).catch(e => console.error('Handle error:', e));
171
- }
172
- } catch (e) {
173
- if (!running) break;
174
- const errMsg = e.message || '';
175
- const isConnReset = errMsg.includes('ECONNRESET') || errMsg.includes('fetch failed');
176
- if (isConnReset) {
177
- retryCount++;
178
- const delay = Math.min(2000 * retryCount, 15000);
179
- console.error(`[bot] Connection error (${retryCount}), retry in ${delay}ms...`);
180
- await new Promise(r => setTimeout(r, delay));
181
- } else {
182
- console.error('Polling error:', e);
183
- await new Promise(r => setTimeout(r, 2000));
184
- }
185
- }
186
- }
187
-
263
+ console.log(`✅ ${botInstances.length} bot instance(s) running`);
264
+ console.log('📡 Listening for WeChat messages...');
265
+
266
+ await new Promise(resolve => {
267
+ globalThis.__weixinBotShutdownAndExit = (restart) => {
268
+ shutdown(restart);
269
+ resolve();
270
+ };
271
+ // 收到信号时清理 opencode server 后再退出
272
+ const handleSignal = () => { shutdown(false); resolve(); };
273
+ process.on('SIGINT', handleSignal);
274
+ process.on('SIGTERM', handleSignal);
275
+ });
276
+
188
277
  if (shouldRestart) {
189
278
  console.log('✅ Bot shutdown complete, exiting for restart...');
190
279
  process.exit(0);