@yvhitxcel/opencode-remote 0.17.0 → 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.
- package/LICENSE +21 -0
- package/README.md +70 -1
- package/dist/cli.js +120 -9
- package/dist/core/adapter.js +12 -0
- package/dist/core/agent-registry.js +77 -0
- package/dist/core/crypto.js +80 -0
- package/dist/core/git-push.js +38 -15
- package/dist/core/handler.js +293 -0
- package/dist/core/log.js +92 -0
- package/dist/core/lru.js +98 -0
- package/dist/core/qiniu.js +2 -2
- package/dist/core/retry.js +46 -0
- package/dist/core/router.js +18 -6
- package/dist/core/state.js +190 -0
- package/dist/core/stats.js +115 -0
- package/dist/feishu/adapter.js +0 -1
- package/dist/feishu/bot.js +4 -2
- package/dist/feishu/commands.js +2 -2
- package/dist/feishu/handler.js +8 -177
- package/dist/opencode/client.js +78 -56
- package/dist/patch_spawn.js +1 -0
- package/dist/plugins/agents/claude-code/index.js +59 -51
- package/dist/plugins/agents/codex/index.js +32 -6
- package/dist/plugins/agents/copilot/index.js +32 -6
- package/dist/plugins/agents/opencode/index.js +36 -14
- package/dist/telegram/adapter.js +19 -3
- package/dist/weixin/adapter.js +37 -15
- package/dist/weixin/api.js +38 -17
- package/dist/weixin/bot.js +58 -23
- package/dist/weixin/commands.js +134 -8
- package/dist/weixin/handler.js +12 -274
- package/dist/weixin/user-adapter-map.js +11 -0
- package/package.json +5 -3
package/dist/weixin/adapter.js
CHANGED
|
@@ -7,13 +7,35 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
7
7
|
const processedMessages = new Map();
|
|
8
8
|
const DEDUP_WINDOW_MS = 30_000;
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
// 定期清理 contextTokens 和 typingTickets,防内存泄漏
|
|
11
|
+
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
|
|
12
|
+
let cleanupTimer = null;
|
|
13
|
+
function startCleanup() {
|
|
14
|
+
if (cleanupTimer) return;
|
|
15
|
+
cleanupTimer = setInterval(() => {
|
|
16
|
+
const cutoff = Date.now() - CLEANUP_INTERVAL_MS;
|
|
17
|
+
for (const [k, v] of contextTokens) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) contextTokens.delete(k); }
|
|
18
|
+
for (const [k, v] of typingTickets) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) typingTickets.delete(k); }
|
|
19
|
+
if (processedMessages.size > 1000) {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
for (const [id, ts] of processedMessages.entries()) {
|
|
22
|
+
if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}, CLEANUP_INTERVAL_MS);
|
|
26
|
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
27
|
+
}
|
|
28
|
+
startCleanup();
|
|
29
|
+
|
|
30
|
+
function isDuplicate(messageId, contentKey) {
|
|
31
|
+
// 优先用内容去重: 微信两条消息可能 messageId 不同但内容相同
|
|
32
|
+
const key = contentKey || messageId;
|
|
33
|
+
if (!key) return false;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const seenAt = processedMessages.get(key);
|
|
36
|
+
if (seenAt && now - seenAt < DEDUP_WINDOW_MS) return true;
|
|
37
|
+
processedMessages.set(key, now);
|
|
15
38
|
if (processedMessages.size > 1000) {
|
|
16
|
-
const now = Date.now();
|
|
17
39
|
for (const [id, ts] of processedMessages.entries()) {
|
|
18
40
|
if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
|
|
19
41
|
}
|
|
@@ -29,7 +51,8 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
29
51
|
_token: token,
|
|
30
52
|
_botId: botId,
|
|
31
53
|
async reply(threadId, text) {
|
|
32
|
-
let
|
|
54
|
+
let entry = contextTokens.get(threadId);
|
|
55
|
+
let contextToken = entry?.value || entry;
|
|
33
56
|
let retryCount = 0;
|
|
34
57
|
const maxRetries = 2;
|
|
35
58
|
|
|
@@ -40,7 +63,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
40
63
|
const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
|
|
41
64
|
contextToken = r.context_token || r.typing_ticket;
|
|
42
65
|
if (contextToken) {
|
|
43
|
-
contextTokens.set(threadId, contextToken);
|
|
66
|
+
contextTokens.set(threadId, { value: contextToken, _ts: Date.now() });
|
|
44
67
|
console.log(`[Weixin] Got contextToken: ${contextToken.slice(0, 8)}...`);
|
|
45
68
|
} else if (r.errcode === -14) {
|
|
46
69
|
console.log(`[Weixin] Session timeout, retrying with fresh token...`);
|
|
@@ -88,24 +111,24 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
88
111
|
throw err;
|
|
89
112
|
},
|
|
90
113
|
async sendTypingIndicator(threadId) {
|
|
91
|
-
const
|
|
92
|
-
let ticket =
|
|
114
|
+
const entry = typingTickets.get(threadId);
|
|
115
|
+
let ticket = entry?.value || entry;
|
|
93
116
|
|
|
94
117
|
if (!ticket) {
|
|
95
118
|
try {
|
|
96
|
-
const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: contextTokens.get(threadId) });
|
|
119
|
+
const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: (contextTokens.get(threadId) || {}).value });
|
|
97
120
|
if (r.errcode === -14) {
|
|
98
121
|
contextTokens.delete(threadId);
|
|
99
122
|
typingTickets.delete(threadId);
|
|
100
123
|
const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
|
|
101
124
|
ticket = freshConfig.typing_ticket;
|
|
102
125
|
if (freshConfig.context_token) {
|
|
103
|
-
contextTokens.set(threadId, freshConfig.context_token);
|
|
126
|
+
contextTokens.set(threadId, { value: freshConfig.context_token, _ts: Date.now() });
|
|
104
127
|
}
|
|
105
128
|
} else {
|
|
106
129
|
ticket = r.typing_ticket;
|
|
107
130
|
}
|
|
108
|
-
if (ticket) typingTickets.set(threadId, ticket);
|
|
131
|
+
if (ticket) typingTickets.set(threadId, { value: ticket, _ts: Date.now() });
|
|
109
132
|
} catch { console.debug('[typing] getConfig failed'); }
|
|
110
133
|
}
|
|
111
134
|
if (ticket) {
|
|
@@ -118,7 +141,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
118
141
|
try {
|
|
119
142
|
const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
|
|
120
143
|
if (freshConfig.typing_ticket) {
|
|
121
|
-
typingTickets.set(threadId, freshConfig.typing_ticket);
|
|
144
|
+
typingTickets.set(threadId, { value: freshConfig.typing_ticket, _ts: Date.now() });
|
|
122
145
|
await sendTyping({ baseUrl, token, body: { ilink_user_id: threadId, typing_ticket: freshConfig.typing_ticket, status: 1 } });
|
|
123
146
|
}
|
|
124
147
|
} catch { console.debug('[typing] retry getConfig failed'); }
|
|
@@ -132,4 +155,3 @@ function createWeixinAdapter(baseUrl, token, botId) {
|
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
export { createWeixinAdapter };
|
|
135
|
-
export default createWeixinAdapter;
|
package/dist/weixin/api.js
CHANGED
|
@@ -129,26 +129,47 @@ export async function getUpdates(params) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
|
-
* Send a message
|
|
132
|
+
* Send a message with rate limit retry
|
|
133
133
|
*/
|
|
134
134
|
export async function sendMessage(params) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
171
|
}
|
|
172
|
+
throw lastErr;
|
|
152
173
|
}
|
|
153
174
|
/**
|
|
154
175
|
* Get bot config (includes typing_ticket)
|
package/dist/weixin/bot.js
CHANGED
|
@@ -9,8 +9,22 @@ import { DEFAULT_BASE_URL } from './types.js';
|
|
|
9
9
|
import { createWeixinAdapter } from './adapter.js';
|
|
10
10
|
import { handleMessage } from './handler.js';
|
|
11
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');
|
|
16
30
|
const CREDENTIALS_DIR = join(WEIXIN_DIR, 'credentials');
|
|
@@ -18,7 +32,6 @@ const INSTANCE_ID = process.env.OPENCODE_INSTANCE_ID || 'default';
|
|
|
18
32
|
const CREDENTIALS_FILE = INSTANCE_ID === 'default'
|
|
19
33
|
? join(WEIXIN_DIR, 'credentials.json')
|
|
20
34
|
: join(WEIXIN_DIR, `credentials-${INSTANCE_ID}.json`);
|
|
21
|
-
const RESTART_NOTIFY_FILE = join(WEIXIN_DIR, 'restart-notify.json');
|
|
22
35
|
|
|
23
36
|
const botInstances = [];
|
|
24
37
|
|
|
@@ -62,7 +75,16 @@ export function loadAllCredentials() {
|
|
|
62
75
|
const files = readdirSync(CREDENTIALS_DIR).filter(f => f.endsWith('.json'));
|
|
63
76
|
if (files.length > 0) {
|
|
64
77
|
return files.map(f => {
|
|
65
|
-
try {
|
|
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
|
+
}
|
|
66
88
|
catch (e) { console.debug('[credentials] Failed to parse:', f, e.message); return null; }
|
|
67
89
|
}).filter(Boolean);
|
|
68
90
|
}
|
|
@@ -81,7 +103,10 @@ export function loadWeixinCredentials() {
|
|
|
81
103
|
export function saveWeixinCredentials(creds) {
|
|
82
104
|
ensureDirs();
|
|
83
105
|
const filePath = join(CREDENTIALS_DIR, `credentials-${creds.accountId}.json`);
|
|
84
|
-
|
|
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');
|
|
85
110
|
try { const s = statSync(filePath); chmodSync(filePath, (s.mode & 0o777) | 0o600); } catch (e) { console.warn('[credentials] chmod failed:', e.message); }
|
|
86
111
|
}
|
|
87
112
|
|
|
@@ -108,9 +133,9 @@ async function runPollingLoop(adapter, baseUrl, token, openCodeSessions, signal)
|
|
|
108
133
|
if (!fromUserId || !text) continue;
|
|
109
134
|
userAdapterMap.set(fromUserId, adapter);
|
|
110
135
|
const messageId = msg.message_id?.toString();
|
|
111
|
-
if (adapter.isDuplicate(messageId)) continue;
|
|
112
|
-
if (msg.context_token) adapter.contextTokens.set(fromUserId, msg.context_token);
|
|
113
|
-
handleMessage(adapter, { platform: 'weixin', threadId: fromUserId, userId: fromUserId, messageId }, text, openCodeSessions)
|
|
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); }
|
|
114
139
|
}
|
|
115
140
|
} catch (e) {
|
|
116
141
|
if (signal.aborted) break;
|
|
@@ -142,6 +167,9 @@ export function addBotInstance(creds, openCodeSessions) {
|
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
export async function startWeixinBot(botConfig, restartFn) {
|
|
170
|
+
// 仅在子进程启动时初始化日志和状态 (不在父进程 import 时)
|
|
171
|
+
initBot();
|
|
172
|
+
process.on('unhandledRejection', (reason) => { console.error('[bot] Unhandled Rejection:', reason); });
|
|
145
173
|
if (restartFn) _restartCallback = restartFn;
|
|
146
174
|
console.log('');
|
|
147
175
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
@@ -160,25 +188,32 @@ export async function startWeixinBot(botConfig, restartFn) {
|
|
|
160
188
|
}
|
|
161
189
|
const firstCreds = credentialsList[0];
|
|
162
190
|
console.log(`Using account: ${firstCreds.accountId}${credentialsList.length > 1 ? ` (+${credentialsList.length - 1} more)` : ''}`);
|
|
163
|
-
const openCodeSessions = new
|
|
164
|
-
|
|
165
|
-
|
|
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();
|
|
166
194
|
|
|
195
|
+
let opencodeServer = null;
|
|
167
196
|
try {
|
|
168
197
|
const opencode = await initOpenCode();
|
|
169
198
|
if (opencode) {
|
|
199
|
+
opencodeServer = opencode.server;
|
|
200
|
+
globalThis.__opencodeServer = opencode.server;
|
|
201
|
+
console.log('OpenCode ready');
|
|
170
202
|
const result = await opencode.client.session.list();
|
|
171
203
|
if (!result.error && result.data && result.data.length > 0) {
|
|
172
204
|
const sorted = result.data.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
|
173
205
|
const latest = sorted[0];
|
|
174
206
|
console.log(`Latest OpenCode session: ${latest.title || 'Untitled'} (${latest.id.slice(0, 8)}...)`);
|
|
175
207
|
if (latest.directory) {
|
|
176
|
-
|
|
177
|
-
|
|
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;
|
|
178
213
|
}
|
|
179
214
|
}
|
|
180
215
|
}
|
|
181
|
-
} catch (e) { console.
|
|
216
|
+
} catch (e) { console.error('Failed to init OpenCode:', e); }
|
|
182
217
|
|
|
183
218
|
if (!getAuthStatus().weixin) {
|
|
184
219
|
console.log('\n🔒 Bot not secured! First user to send /start becomes owner.\n');
|
|
@@ -189,16 +224,9 @@ export async function startWeixinBot(botConfig, restartFn) {
|
|
|
189
224
|
addBotInstance(creds, openCodeSessions);
|
|
190
225
|
}
|
|
191
226
|
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
|
|
195
|
-
const data = JSON.parse(readFileSync(RESTART_NOTIFY_FILE, 'utf8'));
|
|
196
|
-
if (data.threadId && Date.now() - data.time < 60000 && firstAdapter) {
|
|
197
|
-
await firstAdapter.reply(data.threadId, '✅ Bot 重启完成!');
|
|
198
|
-
}
|
|
199
|
-
unlinkSync(RESTART_NOTIFY_FILE);
|
|
200
|
-
}
|
|
201
|
-
} catch (e) { console.warn('[restart-notify] Failed to read restart file:', e.message); }
|
|
227
|
+
// IPC 心跳:每 30s 通知父进程还活着
|
|
228
|
+
const hbTimer = setInterval(() => { try { process.send?.({ type: 'heartbeat', ts: Date.now() }); } catch {} }, 30_000);
|
|
229
|
+
if (hbTimer.unref) hbTimer.unref();
|
|
202
230
|
|
|
203
231
|
let shouldRestart = false;
|
|
204
232
|
const shutdown = (restart = false) => {
|
|
@@ -207,7 +235,8 @@ export async function startWeixinBot(botConfig, restartFn) {
|
|
|
207
235
|
for (const instance of botInstances) {
|
|
208
236
|
try { instance.abortController.abort(); } catch (e) { }
|
|
209
237
|
}
|
|
210
|
-
|
|
238
|
+
// 关掉 opencode server 进程,防重启后端口冲突
|
|
239
|
+
try { globalThis.__opencodeServer?.kill?.(); } catch (e) { console.warn('[shutdown] Server kill error:', e.message); }
|
|
211
240
|
openCodeSessions.clear();
|
|
212
241
|
};
|
|
213
242
|
|
|
@@ -216,6 +245,7 @@ export async function startWeixinBot(botConfig, restartFn) {
|
|
|
216
245
|
|
|
217
246
|
if (process.env.OPENCODE_RESTART === '1') {
|
|
218
247
|
try {
|
|
248
|
+
const firstAdapter = botInstances[0]?.adapter;
|
|
219
249
|
const restartInfoPath = join(process.env.HOME || process.cwd(), '.opencode-remote', '.restart_user.json');
|
|
220
250
|
if (existsSync(restartInfoPath)) {
|
|
221
251
|
const restartInfo = JSON.parse(readFileSync(restartInfoPath, 'utf8'));
|
|
@@ -231,12 +261,17 @@ export async function startWeixinBot(botConfig, restartFn) {
|
|
|
231
261
|
}
|
|
232
262
|
|
|
233
263
|
console.log(`✅ ${botInstances.length} bot instance(s) running`);
|
|
264
|
+
console.log('📡 Listening for WeChat messages...');
|
|
234
265
|
|
|
235
266
|
await new Promise(resolve => {
|
|
236
267
|
globalThis.__weixinBotShutdownAndExit = (restart) => {
|
|
237
268
|
shutdown(restart);
|
|
238
269
|
resolve();
|
|
239
270
|
};
|
|
271
|
+
// 收到信号时清理 opencode server 后再退出
|
|
272
|
+
const handleSignal = () => { shutdown(false); resolve(); };
|
|
273
|
+
process.on('SIGINT', handleSignal);
|
|
274
|
+
process.on('SIGTERM', handleSignal);
|
|
240
275
|
});
|
|
241
276
|
|
|
242
277
|
if (shouldRestart) {
|
package/dist/weixin/commands.js
CHANGED
|
@@ -4,10 +4,14 @@ import { abortSession, initOpenCode, listProviders, getThreadModel, setThreadMod
|
|
|
4
4
|
import { claimOwnership, hasOwner } from '../core/auth.js';
|
|
5
5
|
import { registry } from '../core/registry.js';
|
|
6
6
|
import { deleteFromQiniu } from '../core/qiniu.js';
|
|
7
|
+
import { formatInfo, incr, incrKey } from '../core/stats.js';
|
|
8
|
+
import { listAgentProcesses } from '../core/agent-registry.js';
|
|
9
|
+
import { threadHistory } from '../core/state.js';
|
|
7
10
|
import { join } from 'path';
|
|
8
11
|
import { existsSync } from 'fs';
|
|
9
12
|
import { homedir } from 'os';
|
|
10
13
|
import { DEFAULT_BASE_URL } from './types.js';
|
|
14
|
+
import { threadAgent } from '../core/state.js';
|
|
11
15
|
import { userAdapterMap } from './user-adapter-map.js';
|
|
12
16
|
|
|
13
17
|
// 共享会话
|
|
@@ -27,7 +31,7 @@ export function removeSharedMember(threadId) {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// 线程级活跃 agent 追踪
|
|
30
|
-
export
|
|
34
|
+
export { threadAgent };
|
|
31
35
|
|
|
32
36
|
async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
|
|
33
37
|
const agent = registry.findAgent(agentName);
|
|
@@ -65,12 +69,21 @@ async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
|
|
|
65
69
|
threadAgent.set(ctx.threadId, agentName);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
// 兼容旧版 -c 前缀(清理历史中残留的 -c)
|
|
73
|
+
const cleanPrompt = prompt.replace(/^-c\s+/, '').trim();
|
|
74
|
+
|
|
75
|
+
adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
|
|
76
|
+
|
|
77
|
+
// 传入 threadHistory 让对话有上下文
|
|
78
|
+
const history = threadHistory.get(ctx.threadId) || [];
|
|
79
|
+
const recentHistory = history.slice(-20);
|
|
69
80
|
|
|
70
81
|
try {
|
|
71
|
-
const response = await agent.sendPrompt(agentName,
|
|
82
|
+
const response = await agent.sendPrompt(agentName, cleanPrompt, recentHistory, { projectDir: globalThis.__autoProjectDir, threadId: ctx.threadId });
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
// 更新历史(用清理后的 prompt)
|
|
85
|
+
history.push({ role: 'user', content: cleanPrompt }, { role: 'assistant', content: response || '' });
|
|
86
|
+
threadHistory.set(ctx.threadId, history);
|
|
74
87
|
|
|
75
88
|
const chunks = splitMessage(response || '无响应');
|
|
76
89
|
for (const chunk of chunks) {
|
|
@@ -85,11 +98,15 @@ async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
|
|
|
85
98
|
return true;
|
|
86
99
|
}
|
|
87
100
|
|
|
88
|
-
async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
101
|
+
async function handleCommand(adapter, ctx, platform, command, arg, openCodeSessions) {
|
|
102
|
+
// Count this command invocation
|
|
103
|
+
incr('messagesSent');
|
|
104
|
+
incrKey('commandsByType', command);
|
|
105
|
+
|
|
89
106
|
const session = {};
|
|
90
107
|
switch (command) {
|
|
91
108
|
case 'start': {
|
|
92
|
-
const result = claimOwnership(
|
|
109
|
+
const result = claimOwnership(platform, ctx.userId);
|
|
93
110
|
if (result.success) {
|
|
94
111
|
if (result.message === 'claimed') {
|
|
95
112
|
await adapter.reply(ctx.threadId, `🔐 安全设置完成!你是此 bot 的唯一所有者。\n\n发送消息给 OpenCode 开始工作\n/help 查看指令`);
|
|
@@ -107,6 +124,8 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
|
107
124
|
case 'restart': {
|
|
108
125
|
console.log('[bot] restart command received');
|
|
109
126
|
await adapter.reply(ctx.threadId, '🔄 正在重启 bot...');
|
|
127
|
+
// 关掉 opencode server,防新子进程端口冲突
|
|
128
|
+
try { globalThis.__opencodeServer?.kill?.(); } catch {}
|
|
110
129
|
const fs = await import('fs');
|
|
111
130
|
const remoteDir = join(process.env.HOME || process.cwd(), '.opencode-remote');
|
|
112
131
|
if (!fs.existsSync(remoteDir)) {
|
|
@@ -131,6 +150,73 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
|
131
150
|
return true;
|
|
132
151
|
}
|
|
133
152
|
|
|
153
|
+
case 'esc': {
|
|
154
|
+
const session = openCodeSessions?.get(ctx.threadId);
|
|
155
|
+
const parts = [];
|
|
156
|
+
// 1. 杀 CLI 进程 (cc/cx/copilot 模式)
|
|
157
|
+
const { killAgentProcess, getAgentProcess } = await import('../core/agent-registry.js');
|
|
158
|
+
const ap = getAgentProcess(ctx.threadId);
|
|
159
|
+
if (ap) {
|
|
160
|
+
const r = killAgentProcess(ctx.threadId);
|
|
161
|
+
parts.push(`🛑 ${r.agentName} 子进程已终止 (pid ${ap.process.pid})`);
|
|
162
|
+
}
|
|
163
|
+
// 2. 中断 OpenCode SDK session
|
|
164
|
+
if (session) {
|
|
165
|
+
const ok = await abortSession(session);
|
|
166
|
+
parts.push(ok ? '🛑 OpenCode session 已中断' : '⚠️ OpenCode session 中断失败');
|
|
167
|
+
}
|
|
168
|
+
if (parts.length === 0) {
|
|
169
|
+
await adapter.reply(ctx.threadId, '⚠️ 没有活跃任务');
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
await adapter.reply(ctx.threadId, parts.join('\n'));
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 'status': {
|
|
177
|
+
const session = openCodeSessions?.get(ctx.threadId);
|
|
178
|
+
if (!session) {
|
|
179
|
+
await adapter.reply(ctx.threadId, '⚠️ 当前线程无 session\n发送任意消息创建 session');
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const r = await session.client.session.status();
|
|
184
|
+
const all = r.data || {};
|
|
185
|
+
const s = all[session.sessionId];
|
|
186
|
+
if (!s) {
|
|
187
|
+
await adapter.reply(ctx.threadId, `📊 Session: ${session.sessionId.slice(0, 8)}\n状态: unknown (server 未返回)`);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const icon = s.type === 'idle' ? '🟢' : s.type === 'busy' ? '🔴' : '🟡';
|
|
191
|
+
const label = s.type === 'idle' ? '待命' : s.type === 'busy' ? '活跃' : `重试中 (attempt ${s.attempt})`;
|
|
192
|
+
let msg = `${icon} ${label}\nSession: ${session.sessionId.slice(0, 8)}`;
|
|
193
|
+
if (s.type === 'retry' && s.next) {
|
|
194
|
+
const wait = Math.max(0, Math.round((s.next - Date.now()) / 1000));
|
|
195
|
+
msg += `\n下次重试: ${wait}s 后`;
|
|
196
|
+
}
|
|
197
|
+
await adapter.reply(ctx.threadId, msg);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
await adapter.reply(ctx.threadId, `❌ 状态查询失败: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'info': {
|
|
205
|
+
try {
|
|
206
|
+
const agentChildren = listAgentProcesses().length;
|
|
207
|
+
const activeThreads = threadHistory.size;
|
|
208
|
+
const msg = formatInfo({
|
|
209
|
+
version: process.env.npm_package_version || 'dev',
|
|
210
|
+
activeThreads,
|
|
211
|
+
agentChildren,
|
|
212
|
+
});
|
|
213
|
+
await adapter.reply(ctx.threadId, msg);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
await adapter.reply(ctx.threadId, `❌ /info 失败: ${e.message}`);
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
134
220
|
case 'delete': {
|
|
135
221
|
const keyToDelete = arg ? arg.trim() : null;
|
|
136
222
|
|
|
@@ -335,7 +421,7 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
|
335
421
|
|
|
336
422
|
case 'share': {
|
|
337
423
|
const val = arg?.trim().toLowerCase();
|
|
338
|
-
const isOwner = hasOwner(
|
|
424
|
+
const isOwner = hasOwner(platform) && claimOwnership(platform, ctx.userId);
|
|
339
425
|
|
|
340
426
|
if (!val || val === 'status') {
|
|
341
427
|
const members = [...sharedRoom.members].join(', ') || '无';
|
|
@@ -501,10 +587,48 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
|
501
587
|
return true;
|
|
502
588
|
}
|
|
503
589
|
|
|
590
|
+
case 'lab': {
|
|
591
|
+
const { spawnSync } = await import('child_process');
|
|
592
|
+
const { existsSync } = await import('fs');
|
|
593
|
+
const projectDir = globalThis.__autoProjectDir || process.cwd();
|
|
594
|
+
if (!existsSync(`${projectDir}/bridge/bin/lab.mjs`)) {
|
|
595
|
+
await adapter.reply(ctx.threadId, '❌ 此指令仅在 openchat 项目下可用');
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
const subCmd = ctx.arg || 'status';
|
|
599
|
+
// Whitelist: only alnum + - and _ allowed. Defense-in-depth even though
|
|
600
|
+
// spawnSync with shell:false would block shell injection on its own.
|
|
601
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(subCmd)) {
|
|
602
|
+
await adapter.reply(ctx.threadId, `❌ /lab 子命令非法: "${subCmd}"\n仅允许字母数字 + -_`);
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
// spawnSync with shell:false — user input is passed as argv element,
|
|
607
|
+
// never interpreted by cmd.exe. lab.mjs validates the cmd via its
|
|
608
|
+
// own if/else chain and falls through to showUsage() for unknown cmds.
|
|
609
|
+
const result = spawnSync('node', ['bridge/bin/lab.mjs', subCmd], {
|
|
610
|
+
cwd: projectDir,
|
|
611
|
+
encoding: 'utf8',
|
|
612
|
+
timeout: 15000,
|
|
613
|
+
maxBuffer: 2048 * 1024,
|
|
614
|
+
shell: false,
|
|
615
|
+
});
|
|
616
|
+
if (result.error) throw result.error;
|
|
617
|
+
const out = result.stdout || '';
|
|
618
|
+
// 格式化输出
|
|
619
|
+
const formatted = formatLabOutput(out, subCmd);
|
|
620
|
+
await adapter.reply(ctx.threadId, `📋 Lab ${subCmd}\n${formatted}`);
|
|
621
|
+
} catch (e) {
|
|
622
|
+
const errMsg = e.stderr || e.message || String(e);
|
|
623
|
+
await adapter.reply(ctx.threadId, `❌ Lab 错误: ${errMsg.slice(0, 500)}`);
|
|
624
|
+
}
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
504
628
|
case 'deploy': {
|
|
505
629
|
const { gitPush } = await import('../core/git-push.js');
|
|
506
630
|
await adapter.reply(ctx.threadId, '📤 正在推送代码...');
|
|
507
|
-
const result = gitPush({ message: ctx.arg || undefined });
|
|
631
|
+
const result = gitPush({ message: ctx.arg || undefined, branch: undefined });
|
|
508
632
|
if (result.ok) {
|
|
509
633
|
await adapter.reply(ctx.threadId, `✅ 推送成功: ${result.successUrl}`);
|
|
510
634
|
} else {
|
|
@@ -519,4 +643,6 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
|
|
|
519
643
|
}
|
|
520
644
|
}
|
|
521
645
|
|
|
646
|
+
import { formatLabOutput } from '../core/handler.js';
|
|
647
|
+
|
|
522
648
|
export { handleAgentSwitch, handleCommand };
|