wechat-opencode-bot 0.1.0 → 0.2.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/README.md +3 -3
- package/package.json +1 -1
- package/src/bridge.js +191 -47
- package/src/cli.js +57 -8
- package/src/paths.js +2 -6
package/README.md
CHANGED
|
@@ -41,11 +41,11 @@ wechat-bot install # macOS 生成 launchd plist 并加载
|
|
|
41
41
|
| 变量 | 默认值 | 说明 |
|
|
42
42
|
|---|---|---|
|
|
43
43
|
| `WECHAT_BOT_HOME` | `~/.wechat-bot` | 数据目录(sessions/tasks/media/logs/pids) |
|
|
44
|
-
| `
|
|
45
|
-
| `WECHAT_PROFILE` | `default` | cc-wechat 账号 profile |
|
|
44
|
+
| `WECHAT_PROFILE` | `default` | cc-wechat 账号 profile,状态目录为 `~/.claude/channels/wechat/<profile>` |
|
|
46
45
|
| `OPENCODE_SERVER_URL` | `http://127.0.0.1:4100` | opencode serve 地址 |
|
|
47
46
|
| `OPENCODE_PORT` | `4100` | opencode serve 端口 |
|
|
48
|
-
| `OPENCODE_BIN` |
|
|
47
|
+
| `OPENCODE_BIN` | 自动探测 | opencode 可执行文件路径 |
|
|
48
|
+
| `OPENCODE_SERVER_PASSWORD` | 空 | 可选: opencode serve 鉴权密码(设置后 bridge 自动携带) |
|
|
49
49
|
| `LLM_TIMEOUT_MS` | `300000` | LLM 响应超时 |
|
|
50
50
|
| `BOT_PREFIX` | 空 | 仅响应带此前缀的消息 |
|
|
51
51
|
|
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -12,7 +12,6 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import {
|
|
14
14
|
findCcWechatDir,
|
|
15
|
-
STATE_DIR,
|
|
16
15
|
SESSION_MAP_FILE,
|
|
17
16
|
MEDIA_DIR,
|
|
18
17
|
TASKS_FILE,
|
|
@@ -54,8 +53,23 @@ let pollingAbort = null;
|
|
|
54
53
|
const sessionMap = loadSessionMap();
|
|
55
54
|
const typingTicketCache = new Map();
|
|
56
55
|
const processedIds = new Set();
|
|
56
|
+
const userQueues = new Map(); // userId -> Promise 链,同用户串行、不同用户并发
|
|
57
57
|
let account = null;
|
|
58
58
|
|
|
59
|
+
/** 将消息处理任务按用户排队:同一用户串行,不同用户并发 */
|
|
60
|
+
function enqueueHandle(msg) {
|
|
61
|
+
const fromUser = msg.from_user_id ?? '';
|
|
62
|
+
const prev = userQueues.get(fromUser) || Promise.resolve();
|
|
63
|
+
const next = prev
|
|
64
|
+
.then(() => handleMessage(msg))
|
|
65
|
+
.catch((err) => log(`处理消息失败 from=${fromUser}: ${String(err)}`))
|
|
66
|
+
.finally(() => {
|
|
67
|
+
if (userQueues.get(fromUser) === next) userQueues.delete(fromUser);
|
|
68
|
+
});
|
|
69
|
+
userQueues.set(fromUser, next);
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
function loadSessionMap() {
|
|
60
74
|
try {
|
|
61
75
|
if (existsSync(SESSION_MAP_FILE)) {
|
|
@@ -204,17 +218,18 @@ async function fireTask(task) {
|
|
|
204
218
|
const prompt = `【定时任务触发】${task.prompt}\n请据此生成一条发给用户的微信消息。`;
|
|
205
219
|
let reply = '';
|
|
206
220
|
try {
|
|
207
|
-
reply = await
|
|
221
|
+
reply = await askOpenCodeStream(sessionId, prompt, async (chunk) => {
|
|
222
|
+
const plainText = stripMarkdown(chunk);
|
|
223
|
+
if (!plainText.trim()) return;
|
|
224
|
+
for (const c of chunkText(plainText, 3900)) {
|
|
225
|
+
await sendMessage(account.token, task.userId, c, '', account.baseUrl, undefined);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
208
228
|
} catch (err) {
|
|
209
229
|
reply = `[定时任务执行失败: ${String(err.message ?? err)}]`;
|
|
210
230
|
}
|
|
211
231
|
if (reply) {
|
|
212
|
-
|
|
213
|
-
const chunks = chunkText(plainText, 3900);
|
|
214
|
-
for (const chunk of chunks) {
|
|
215
|
-
await sendMessage(account.token, task.userId, chunk, '', account.baseUrl, undefined);
|
|
216
|
-
}
|
|
217
|
-
log(`定时任务已推送 ${task.id} chunks=${chunks.length}`);
|
|
232
|
+
log(`定时任务已推送 ${task.id}`);
|
|
218
233
|
}
|
|
219
234
|
// 一次性任务执行后移除
|
|
220
235
|
if (task.parsed.type === 'once') {
|
|
@@ -280,6 +295,16 @@ function sleep(ms, signal) {
|
|
|
280
295
|
}
|
|
281
296
|
|
|
282
297
|
// ─── opencode serve 调用 ─────────────────────────────
|
|
298
|
+
// 可选鉴权:与 cli.js startServe 的 OPENCODE_SERVER_PASSWORD 配合
|
|
299
|
+
const SERVER_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || '';
|
|
300
|
+
const authHeader = SERVER_PASSWORD
|
|
301
|
+
? { Authorization: `Basic ${Buffer.from(`opencode:${SERVER_PASSWORD}`).toString('base64')}` }
|
|
302
|
+
: {};
|
|
303
|
+
|
|
304
|
+
function buildHeaders(extra = {}) {
|
|
305
|
+
return { 'Content-Type': 'application/json', ...authHeader, ...extra };
|
|
306
|
+
}
|
|
307
|
+
|
|
283
308
|
async function api(path, options = {}, timeoutMs = 30_000) {
|
|
284
309
|
const ctrl = new AbortController();
|
|
285
310
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
@@ -287,7 +312,7 @@ async function api(path, options = {}, timeoutMs = 30_000) {
|
|
|
287
312
|
const resp = await fetch(`${SERVER_BASE}${path}`, {
|
|
288
313
|
...options,
|
|
289
314
|
signal: ctrl.signal,
|
|
290
|
-
headers:
|
|
315
|
+
headers: buildHeaders(options.headers || {}),
|
|
291
316
|
});
|
|
292
317
|
const text = await resp.text();
|
|
293
318
|
if (!resp.ok) {
|
|
@@ -319,28 +344,149 @@ async function getSessionForUser(userId) {
|
|
|
319
344
|
return session.id;
|
|
320
345
|
}
|
|
321
346
|
|
|
322
|
-
/**
|
|
323
|
-
|
|
324
|
-
|
|
347
|
+
/**
|
|
348
|
+
* 向 opencode session 异步发送消息,通过 SSE 事件流实时获取回复。
|
|
349
|
+
* 流式累积文本,按边界分块回调 onChunk(用于实时推送到微信),
|
|
350
|
+
* 返回完整回复文本(供媒体标记/schedule 处理)。
|
|
351
|
+
*/
|
|
352
|
+
async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
353
|
+
const SYSTEM_HINT = `你是微信自动回复助手,收到 [图片: 路径] 或 [文件: 路径] 标记时,可用 read 工具读取内容。
|
|
325
354
|
如需在回复中发送媒体文件(本地绝对路径),在回复文本中用 [media: /绝对/路径] 标记,一行一个,其余内容按普通文本发送。
|
|
326
355
|
定时任务指令(用户要求定时提醒/推送时使用):
|
|
327
356
|
[schedule: 时间表达式 | 任务提示] 添加定时任务。时间表达式支持: HH:MM(每天) / 每N小时 / 每N分钟 / YYYY-MM-DD HH:MM(一次性)
|
|
328
357
|
[schedule_list] 查看当前定时任务
|
|
329
358
|
[schedule_del: 任务ID] 删除指定定时任务
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
到点后系统会调用你生成内容并主动推送给用户。
|
|
360
|
+
回复要求: 请给出完整、详细、原样的回答,把你在命令行中会输出的全部内容直接发过来,不要缩略、不要只给总结或要点提示,确保用户看到完整答案。如果执行了代码或命令,把关键输出也一并附上。
|
|
361
|
+
重要约束: 本环境是无人工值守的微信会话,不要调用 skill、读取/写入文件、执行 shell 命令等工具,不要触发权限确认。直接基于已有信息回答即可,避免因工具等待权限确认导致回复卡死。`;
|
|
362
|
+
|
|
363
|
+
// 确认 session 存在
|
|
364
|
+
await api(`/session/${sessionId}`, {}, 5000);
|
|
365
|
+
|
|
366
|
+
// 1. 建立 SSE 事件流
|
|
367
|
+
const ctrl = new AbortController();
|
|
368
|
+
const eventFetch = fetch(`${SERVER_BASE}/event`, { signal: ctrl.signal, headers: buildHeaders() });
|
|
369
|
+
// 2. 异步发送消息(204 立即返回)
|
|
370
|
+
await api(
|
|
371
|
+
`/session/${sessionId}/prompt_async`,
|
|
333
372
|
{
|
|
334
373
|
method: 'POST',
|
|
335
374
|
body: JSON.stringify({ parts: [{ type: 'text', text: `${SYSTEM_HINT}\n\n微信消息:\n${text}` }] }),
|
|
336
375
|
},
|
|
337
|
-
|
|
376
|
+
10_000,
|
|
338
377
|
);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
378
|
+
|
|
379
|
+
let accumulated = '';
|
|
380
|
+
let streamingBuffer = '';
|
|
381
|
+
let done = false;
|
|
382
|
+
const reasoningPartIDs = new Set();
|
|
383
|
+
|
|
384
|
+
const resp = await eventFetch;
|
|
385
|
+
if (!resp.ok || !resp.body) {
|
|
386
|
+
throw new Error(`SSE 连接失败: HTTP ${resp.status}`);
|
|
387
|
+
}
|
|
388
|
+
const reader = resp.body.getReader();
|
|
389
|
+
const decoder = new TextDecoder();
|
|
390
|
+
let buf = '';
|
|
391
|
+
|
|
392
|
+
let sendChain = Promise.resolve();
|
|
393
|
+
const flushBuffer = () => {
|
|
394
|
+
if (!streamingBuffer.trim()) return sendChain;
|
|
395
|
+
const chunk = streamingBuffer.trim();
|
|
396
|
+
streamingBuffer = '';
|
|
397
|
+
accumulated += chunk;
|
|
398
|
+
if (onChunk) {
|
|
399
|
+
// 串行发送队列,保证 onChunk 顺序执行(避免并发乱序)
|
|
400
|
+
sendChain = sendChain.then(() => onChunk(chunk));
|
|
401
|
+
}
|
|
402
|
+
return sendChain;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const timeout = setTimeout(async () => {
|
|
406
|
+
if (!done) {
|
|
407
|
+
log(`流式超时(${MAX_LLM_RESPONSE_TIME_MS / 1000}s),abort session ${sessionId}`);
|
|
408
|
+
try {
|
|
409
|
+
await api(`/session/${sessionId}/abort`, { method: 'POST', body: '{}' }, 5000);
|
|
410
|
+
} catch {}
|
|
411
|
+
ctrl.abort();
|
|
412
|
+
done = true;
|
|
413
|
+
}
|
|
414
|
+
}, MAX_LLM_RESPONSE_TIME_MS);
|
|
415
|
+
|
|
416
|
+
// 无进度兜底:长时间没有任何新 part 则视为卡死,abort session 避免永久 busy
|
|
417
|
+
let lastProgress = Date.now();
|
|
418
|
+
const stallTimer = setInterval(async () => {
|
|
419
|
+
if (done) return;
|
|
420
|
+
if (Date.now() - lastProgress > 90_000) {
|
|
421
|
+
log(`流式无进度超时(90s),abort session ${sessionId}`);
|
|
422
|
+
try {
|
|
423
|
+
await api(`/session/${sessionId}/abort`, { method: 'POST', body: '{}' }, 5000);
|
|
424
|
+
} catch {}
|
|
425
|
+
ctrl.abort();
|
|
426
|
+
done = true;
|
|
427
|
+
}
|
|
428
|
+
}, 15_000);
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
while (!done) {
|
|
432
|
+
const { done: streamDone, value } = await reader.read();
|
|
433
|
+
if (streamDone) break;
|
|
434
|
+
buf += decoder.decode(value, { stream: true });
|
|
435
|
+
const lines = buf.split('\n');
|
|
436
|
+
buf = lines.pop();
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
if (!line.startsWith('data:')) continue;
|
|
439
|
+
const raw = line.slice(5).trim();
|
|
440
|
+
if (!raw) continue;
|
|
441
|
+
let evt;
|
|
442
|
+
try {
|
|
443
|
+
evt = JSON.parse(raw);
|
|
444
|
+
} catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const p = evt.properties ?? {};
|
|
448
|
+
if (p.sessionID && p.sessionID !== sessionId) continue;
|
|
449
|
+
switch (evt.type) {
|
|
450
|
+
case 'message.part.delta': {
|
|
451
|
+
if (p.field === 'text' && typeof p.delta === 'string') {
|
|
452
|
+
if (p.partID && reasoningPartIDs.has(p.partID)) break;
|
|
453
|
+
lastProgress = Date.now();
|
|
454
|
+
streamingBuffer += p.delta;
|
|
455
|
+
// 达到块大小或句子边界即推送
|
|
456
|
+
if (
|
|
457
|
+
streamingBuffer.length >= 60 ||
|
|
458
|
+
/[。!?\n]/.test(streamingBuffer.slice(-2))
|
|
459
|
+
) {
|
|
460
|
+
await flushBuffer();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case 'message.part.updated': {
|
|
466
|
+
const part = p.part ?? {};
|
|
467
|
+
if (part.type === 'reasoning' && part.id) {
|
|
468
|
+
reasoningPartIDs.add(part.id);
|
|
469
|
+
}
|
|
470
|
+
lastProgress = Date.now(); // 任何 part 更新都算进度
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
case 'session.status': {
|
|
474
|
+
const st = p.status ?? {};
|
|
475
|
+
if (st.type === 'idle') done = true;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
default:
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
clearTimeout(timeout);
|
|
485
|
+
clearInterval(stallTimer);
|
|
486
|
+
ctrl.abort();
|
|
487
|
+
}
|
|
488
|
+
await flushBuffer();
|
|
489
|
+
return accumulated.trim();
|
|
344
490
|
}
|
|
345
491
|
|
|
346
492
|
// ─── 微信消息处理 ─────────────────────────────────────
|
|
@@ -471,20 +617,31 @@ async function handleMessage(msg) {
|
|
|
471
617
|
return;
|
|
472
618
|
}
|
|
473
619
|
|
|
474
|
-
let
|
|
620
|
+
let fullReply = '';
|
|
475
621
|
try {
|
|
476
|
-
|
|
622
|
+
fullReply = await askOpenCodeStream(sessionId, text, async (chunk) => {
|
|
623
|
+
// 实时推送:边生成边发
|
|
624
|
+
const plainText = stripMarkdown(chunk);
|
|
625
|
+
if (!plainText.trim()) return;
|
|
626
|
+
const chunks = chunkText(plainText, 3900);
|
|
627
|
+
for (const c of chunks) {
|
|
628
|
+
try {
|
|
629
|
+
await sendMessage(account.token, fromUser, c, contextToken, account.baseUrl, messageId);
|
|
630
|
+
} catch (err) {
|
|
631
|
+
log(`发送失败: ${String(err)}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
});
|
|
477
635
|
} catch (err) {
|
|
478
636
|
log(`调用 opencode 失败: ${String(err)}`);
|
|
479
|
-
|
|
637
|
+
fullReply = `[处理失败: ${String(err.message ?? err)}]`;
|
|
480
638
|
}
|
|
481
639
|
|
|
482
640
|
await sendTypingIndicator(fromUser, contextToken, 2);
|
|
483
641
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
let textToSend = processScheduleMarks(
|
|
487
|
-
// 再发送媒体标记
|
|
642
|
+
// 流式已把文本发出,最后处理媒体与 schedule 标记
|
|
643
|
+
if (fullReply) {
|
|
644
|
+
let textToSend = processScheduleMarks(fullReply, fromUser);
|
|
488
645
|
try {
|
|
489
646
|
const { text, mediaSent } = await sendMediaMarks(fromUser, contextToken, textToSend);
|
|
490
647
|
textToSend = text;
|
|
@@ -492,19 +649,8 @@ async function handleMessage(msg) {
|
|
|
492
649
|
} catch (err) {
|
|
493
650
|
log(`媒体处理失败: ${String(err)}`);
|
|
494
651
|
}
|
|
495
|
-
if (textToSend) {
|
|
496
|
-
|
|
497
|
-
const chunks = chunkText(plainText, 3900);
|
|
498
|
-
for (const chunk of chunks) {
|
|
499
|
-
try {
|
|
500
|
-
await sendMessage(account.token, fromUser, chunk, contextToken, account.baseUrl, messageId);
|
|
501
|
-
} catch (err) {
|
|
502
|
-
log(`发送失败: ${String(err)}`);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
log(`已回复 from=${fromUser} chunks=${chunks.length}`);
|
|
506
|
-
} else {
|
|
507
|
-
log(`已回复(仅媒体) from=${fromUser}`);
|
|
652
|
+
if (textToSend.trim()) {
|
|
653
|
+
log(`已回复(流式) from=${fromUser}`);
|
|
508
654
|
}
|
|
509
655
|
}
|
|
510
656
|
}
|
|
@@ -553,11 +699,8 @@ async function pollLoop() {
|
|
|
553
699
|
saveSyncBuf(buf);
|
|
554
700
|
}
|
|
555
701
|
for (const msg of resp.msgs ?? []) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
} catch (err) {
|
|
559
|
-
log(`处理消息失败: ${String(err)}`);
|
|
560
|
-
}
|
|
702
|
+
// 按用户排队并发处理:不同用户并行,同用户串行,避免单条阻塞全部轮询
|
|
703
|
+
enqueueHandle(msg);
|
|
561
704
|
}
|
|
562
705
|
} catch (err) {
|
|
563
706
|
if (pollingAbort?.signal.aborted) return;
|
|
@@ -593,7 +736,7 @@ function stopPolling() {
|
|
|
593
736
|
}
|
|
594
737
|
|
|
595
738
|
// ─── 主入口 ───────────────────────────────────────────
|
|
596
|
-
|
|
739
|
+
async function startBridge() {
|
|
597
740
|
account = getActiveAccount();
|
|
598
741
|
if (!account) {
|
|
599
742
|
log('未找到账号。请先登录: npx cc-wechat login');
|
|
@@ -640,6 +783,7 @@ export async function startBridge() {
|
|
|
640
783
|
}
|
|
641
784
|
|
|
642
785
|
export {
|
|
786
|
+
startBridge,
|
|
643
787
|
parseScheduleExpr,
|
|
644
788
|
addTask,
|
|
645
789
|
deleteTask,
|
package/src/cli.js
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* wechat-bot install 安装自启服务(macOS: launchd;Windows: 计划任务)
|
|
11
11
|
* wechat-bot uninstall 卸载自启服务
|
|
12
12
|
*/
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { join } from 'node:path';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
17
18
|
import {
|
|
18
19
|
SERVER_BASE,
|
|
19
20
|
SERVE_PORT,
|
|
@@ -25,6 +26,37 @@ import {
|
|
|
25
26
|
|
|
26
27
|
const isWin = process.platform === 'win32';
|
|
27
28
|
|
|
29
|
+
/** 探测 opencode 可执行文件真实路径(launchd/计划任务环境 PATH 可能不含它) */
|
|
30
|
+
function resolveOpenCodeBin() {
|
|
31
|
+
const fromEnv = process.env.OPENCODE_BIN;
|
|
32
|
+
if (fromEnv) return fromEnv;
|
|
33
|
+
if (!isWin) {
|
|
34
|
+
const which = spawnSync('which', ['opencode'], { encoding: 'utf-8' });
|
|
35
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
36
|
+
// 常见安装位置
|
|
37
|
+
const candidates = [
|
|
38
|
+
join(homedir(), '.local', 'bin', 'opencode'),
|
|
39
|
+
join(homedir(), '.opencode', 'bin', 'opencode'),
|
|
40
|
+
'/usr/local/bin/opencode',
|
|
41
|
+
'/opt/homebrew/bin/opencode',
|
|
42
|
+
];
|
|
43
|
+
for (const c of candidates) {
|
|
44
|
+
if (existsSync(c)) return c;
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const where = spawnSync('where', ['opencode'], { encoding: 'utf-8' });
|
|
48
|
+
if (where.status === 0 && where.stdout.trim()) return where.stdout.trim().split('\n')[0];
|
|
49
|
+
const winCandidates = [
|
|
50
|
+
join(process.env.USERPROFILE || homedir(), '.local', 'bin', 'opencode.exe'),
|
|
51
|
+
join(process.env.USERPROFILE || homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'),
|
|
52
|
+
];
|
|
53
|
+
for (const c of winCandidates) {
|
|
54
|
+
if (existsSync(c)) return c;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
function usage() {
|
|
29
61
|
console.log(`opencode-wechat-bot
|
|
30
62
|
用法:
|
|
@@ -37,10 +69,11 @@ function usage() {
|
|
|
37
69
|
|
|
38
70
|
环境变量:
|
|
39
71
|
WECHAT_BOT_HOME 数据目录(默认 ~/.wechat-bot)
|
|
40
|
-
|
|
41
|
-
WECHAT_PROFILE cc-wechat 账号 profile(默认 default)
|
|
72
|
+
WECHAT_PROFILE cc-wechat 账号 profile(默认 default),状态目录为 ~/.claude/channels/wechat/<profile>
|
|
42
73
|
OPENCODE_SERVER_URL opencode serve 地址(默认 http://127.0.0.1:4100)
|
|
43
74
|
OPENCODE_PORT opencode serve 端口(默认 4100)
|
|
75
|
+
OPENCODE_BIN opencode 可执行文件路径(默认自动探测)
|
|
76
|
+
OPENCODE_SERVER_PASSWORD 可选: opencode serve 鉴权密码(设置后 bridge 自动携带)
|
|
44
77
|
LLM_TIMEOUT_MS LLM 响应超时(默认 300000)
|
|
45
78
|
BOT_PREFIX 可选: 仅响应带此前缀的消息
|
|
46
79
|
`);
|
|
@@ -48,9 +81,13 @@ function usage() {
|
|
|
48
81
|
|
|
49
82
|
async function startServe() {
|
|
50
83
|
ensureDirs();
|
|
51
|
-
const opencodeBin =
|
|
84
|
+
const opencodeBin = resolveOpenCodeBin();
|
|
85
|
+
if (!opencodeBin) {
|
|
86
|
+
console.error('[serve] 找不到 opencode。请安装或在 OPENCODE_BIN 指定可执行文件路径');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
52
89
|
const args = ['serve', '--port', String(SERVE_PORT)];
|
|
53
|
-
// 禁用 wechat MCP,避免与 bridge
|
|
90
|
+
// 禁用 wechat MCP,避免与 bridge 抢轮询;可选鉴权
|
|
54
91
|
const env = {
|
|
55
92
|
...process.env,
|
|
56
93
|
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
@@ -147,7 +184,13 @@ async function installService() {
|
|
|
147
184
|
['serve'],
|
|
148
185
|
{
|
|
149
186
|
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
150
|
-
PATH:
|
|
187
|
+
PATH: [
|
|
188
|
+
dirname(resolveOpenCodeBin() || ''),
|
|
189
|
+
process.env.PATH || '',
|
|
190
|
+
'/usr/local/bin',
|
|
191
|
+
'/opt/homebrew/bin',
|
|
192
|
+
join(homedir(), '.local', 'bin'),
|
|
193
|
+
].filter(Boolean).join(':'),
|
|
151
194
|
},
|
|
152
195
|
),
|
|
153
196
|
bridge: makePlist(
|
|
@@ -155,7 +198,13 @@ async function installService() {
|
|
|
155
198
|
['bridge'],
|
|
156
199
|
{
|
|
157
200
|
WECHAT_BOT_HOME: BOT_HOME,
|
|
158
|
-
PATH:
|
|
201
|
+
PATH: [
|
|
202
|
+
dirname(resolveOpenCodeBin() || ''),
|
|
203
|
+
process.env.PATH || '',
|
|
204
|
+
'/usr/local/bin',
|
|
205
|
+
'/opt/homebrew/bin',
|
|
206
|
+
join(homedir(), '.local', 'bin'),
|
|
207
|
+
].filter(Boolean).join(':'),
|
|
159
208
|
},
|
|
160
209
|
),
|
|
161
210
|
};
|
package/src/paths.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 约定:
|
|
5
5
|
* - WECHAT_BOT_HOME 桥接自身数据目录(sessions/tasks/media/logs/pids),默认 ~/.wechat-bot
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
6
|
+
* - WECHAT_PROFILE cc-wechat 账号 profile(默认 'default'),状态目录固定为
|
|
7
|
+
* ~/.claude/channels/wechat/<profile>(由 cc-wechat 内部决定)
|
|
8
8
|
* - OPENCODE_SERVER_URL / OPENCODE_PORT opencode serve 地址(默认 http://127.0.0.1:4100)
|
|
9
9
|
*/
|
|
10
10
|
import { homedir } from 'node:os';
|
|
@@ -16,9 +16,6 @@ const require = createRequire(import.meta.url);
|
|
|
16
16
|
|
|
17
17
|
const home = homedir();
|
|
18
18
|
const BOT_HOME = process.env.WECHAT_BOT_HOME || join(home, '.wechat-bot');
|
|
19
|
-
const STATE_DIR =
|
|
20
|
-
process.env.WECHAT_STATE_DIR ||
|
|
21
|
-
join(home, '.claude', 'channels', 'wechat', process.env.WECHAT_PROFILE || 'default');
|
|
22
19
|
const MEDIA_DIR = join(BOT_HOME, 'media');
|
|
23
20
|
const SESSION_MAP_FILE = join(BOT_HOME, 'sessions.json');
|
|
24
21
|
const TASKS_FILE = join(BOT_HOME, 'tasks.json');
|
|
@@ -75,7 +72,6 @@ function ensureDirs() {
|
|
|
75
72
|
export {
|
|
76
73
|
home,
|
|
77
74
|
BOT_HOME,
|
|
78
|
-
STATE_DIR,
|
|
79
75
|
MEDIA_DIR,
|
|
80
76
|
SESSION_MAP_FILE,
|
|
81
77
|
TASKS_FILE,
|