wechat-opencode-bot 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/bridge.js +59 -161
- package/src/cli.js +18 -15
package/README.md
CHANGED
|
@@ -87,6 +87,12 @@ opencode serve 为无人值守环境。建议在 opencode 配置(`~/.config/op
|
|
|
87
87
|
- `bash`/`read` 等放行:微信里可直接让 AI 执行 `tea`、`git` 等命令并读取文件
|
|
88
88
|
- `skill` 设为 `deny`:skill 工具在 serve 环境可能不触发事件结束(卡住会话),建议禁止
|
|
89
89
|
|
|
90
|
+
### PATH 注意事项
|
|
91
|
+
|
|
92
|
+
serve 由 launchd / 计划任务启动时,PATH 常缺少 `/opt/homebrew/bin`(macOS)或 WinGet 目录(Windows),会导致 AI 执行 `which ffmpeg`、`tea` 等找不到工具而误判"未安装"。
|
|
93
|
+
|
|
94
|
+
本工具的 `wechat-bot install` 生成的 plist 已自动补全这些路径(`buildPath()`);`wechat-bot serve` 也会把补全后的 PATH 传给 opencode serve。若你手动用 launchd 跑 `opencode serve`,请自行在 plist 的 `EnvironmentVariables.PATH` 加入 `/opt/homebrew/bin` 等目录。
|
|
95
|
+
|
|
90
96
|
## 数据文件
|
|
91
97
|
|
|
92
98
|
- `sessions.json`:微信用户 → opencode session 映射
|
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -218,19 +218,19 @@ async function fireTask(task) {
|
|
|
218
218
|
const prompt = `【定时任务触发】${task.prompt}\n请据此生成一条发给用户的微信消息。`;
|
|
219
219
|
let reply = '';
|
|
220
220
|
try {
|
|
221
|
-
reply = await
|
|
222
|
-
const plainText = stripMarkdown(chunk);
|
|
223
|
-
if (!plainText.trim()) return;
|
|
224
|
-
for (const c of chunkText(plainText, 3900)) {
|
|
225
|
-
await sendWechat(task.userId, c, '', undefined);
|
|
226
|
-
await sleep(1000);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
221
|
+
reply = await askOpenCode(sessionId, prompt);
|
|
229
222
|
} catch (err) {
|
|
230
223
|
reply = `[定时任务执行失败: ${String(err.message ?? err)}]`;
|
|
231
224
|
}
|
|
232
225
|
if (reply) {
|
|
233
|
-
|
|
226
|
+
// 一次性分段发送
|
|
227
|
+
const plainText = stripMarkdown(reply);
|
|
228
|
+
const chunks = chunkText(plainText, 3900);
|
|
229
|
+
for (const c of chunks) {
|
|
230
|
+
await sendWechat(task.userId, c, '', undefined);
|
|
231
|
+
await sleep(1000);
|
|
232
|
+
}
|
|
233
|
+
log(`定时任务已推送 ${task.id} chunks=${chunks.length}`);
|
|
234
234
|
}
|
|
235
235
|
// 一次性任务执行后移除
|
|
236
236
|
if (task.parsed.type === 'once') {
|
|
@@ -396,11 +396,10 @@ async function getSessionForUser(userId) {
|
|
|
396
396
|
}
|
|
397
397
|
|
|
398
398
|
/**
|
|
399
|
-
* 向 opencode session
|
|
400
|
-
*
|
|
401
|
-
* 返回完整回复文本(供媒体标记/schedule 处理)。
|
|
399
|
+
* 向 opencode session 发送消息并等待完整回复(同步接口)。
|
|
400
|
+
* 返回完整回复文本,供媒体标记/schedule 处理后统一发送。
|
|
402
401
|
*/
|
|
403
|
-
async function
|
|
402
|
+
async function askOpenCode(sessionId, text) {
|
|
404
403
|
const SYSTEM_HINT = `你是微信自动回复助手,收到 [图片: 路径] 或 [文件: 路径] 标记时,可用 read 工具读取内容。
|
|
405
404
|
如需在回复中发送媒体文件(本地绝对路径),在回复文本中用 [media: /绝对/路径] 标记,一行一个,其余内容按普通文本发送。
|
|
406
405
|
定时任务指令(用户要求定时提醒/推送时使用):
|
|
@@ -409,147 +408,36 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
409
408
|
[schedule_del: 任务ID] 删除指定定时任务
|
|
410
409
|
到点后系统会调用你生成内容并主动推送给用户。
|
|
411
410
|
回复要求: 请给出完整、详细、原样的回答,把你在命令行中会输出的全部内容直接发过来,不要缩略、不要只给总结或要点提示,确保用户看到完整答案。如果执行了代码或命令,把关键输出也一并附上。
|
|
412
|
-
微信格式: 不要使用 ASCII 艺术表格(┌─┬─┐ box-drawing 字符),微信会显示错乱。表格/列表改用纯文本(如 "编号 - 标题 (状态)"、"- 项目"
|
|
411
|
+
微信格式: 不要使用 ASCII 艺术表格(┌─┬─┐ box-drawing 字符),微信会显示错乱。表格/列表改用纯文本(如 "编号 - 标题 (状态)"、"- 项目"、用缩进和符号组织),代码块用围栏标记即可。
|
|
412
|
+
截图能力: 用户要求"截图/截屏"网页时,用 headless Chrome 截取 URL 并保存 PNG,再用 [media:] 标记发出:
|
|
413
|
+
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --headless --disable-gpu --screenshot=/tmp/wechat-shot.png --window-size=1280,800 "目标URL"
|
|
414
|
+
截图后回复 [media: /tmp/wechat-shot.png]。URL 若需登录可提示用户;若截图失败把错误告诉用户。`;
|
|
413
415
|
|
|
414
416
|
// 确认 session 存在
|
|
415
417
|
await api(`/session/${sessionId}`, {}, 5000);
|
|
416
418
|
|
|
417
|
-
//
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
// 2. 异步发送消息(204 立即返回)
|
|
421
|
-
await api(
|
|
422
|
-
`/session/${sessionId}/prompt_async`,
|
|
419
|
+
// 同步发送并等待完整回复
|
|
420
|
+
const resp = await api(
|
|
421
|
+
`/session/${sessionId}/message`,
|
|
423
422
|
{
|
|
424
423
|
method: 'POST',
|
|
425
424
|
body: JSON.stringify({ parts: [{ type: 'text', text: `${SYSTEM_HINT}\n\n微信消息:\n${text}` }] }),
|
|
426
425
|
},
|
|
427
|
-
|
|
426
|
+
MAX_LLM_RESPONSE_TIME_MS,
|
|
428
427
|
);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
let sendChain = Promise.resolve();
|
|
444
|
-
const flushBuffer = () => {
|
|
445
|
-
if (!streamingBuffer.trim()) return sendChain;
|
|
446
|
-
const chunk = streamingBuffer.trim();
|
|
447
|
-
streamingBuffer = '';
|
|
448
|
-
accumulated += chunk;
|
|
449
|
-
if (onChunk) {
|
|
450
|
-
// 串行发送队列,保证 onChunk 顺序执行(避免并发乱序)
|
|
451
|
-
sendChain = sendChain.then(() => onChunk(chunk));
|
|
452
|
-
}
|
|
453
|
-
return sendChain;
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const timeout = setTimeout(async () => {
|
|
457
|
-
if (!done) {
|
|
458
|
-
log(`流式超时(${MAX_LLM_RESPONSE_TIME_MS / 1000}s),abort session ${sessionId}`);
|
|
459
|
-
try {
|
|
460
|
-
await api(`/session/${sessionId}/abort`, { method: 'POST', body: '{}' }, 5000);
|
|
461
|
-
} catch {}
|
|
462
|
-
ctrl.abort();
|
|
463
|
-
done = true;
|
|
464
|
-
}
|
|
465
|
-
}, MAX_LLM_RESPONSE_TIME_MS);
|
|
466
|
-
|
|
467
|
-
// 兜底检测:SSE 可能漏发 idle 事件(工具执行后),定期轮询 session 状态判断是否真正结束;
|
|
468
|
-
// 长时间无任何新 part 则视为卡死,abort session 避免永久 busy
|
|
469
|
-
let lastProgress = Date.now();
|
|
470
|
-
const stallTimer = setInterval(async () => {
|
|
471
|
-
if (done) return;
|
|
472
|
-
// 兜底:SSE 漏发 idle 时,主动查 session 状态结束循环
|
|
473
|
-
try {
|
|
474
|
-
const status = await api(`/session/status`, {}, 5000);
|
|
475
|
-
const entry = status?.[sessionId];
|
|
476
|
-
const st = typeof entry?.type === 'string' ? entry.type : entry?.status?.type;
|
|
477
|
-
if (st === 'idle') {
|
|
478
|
-
log(`SSE 未收到 idle,经轮询确认 session 已空闲`);
|
|
479
|
-
done = true;
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
} catch {}
|
|
483
|
-
if (Date.now() - lastProgress > 90_000) {
|
|
484
|
-
log(`流式无进度超时(90s),abort session ${sessionId}`);
|
|
485
|
-
try {
|
|
486
|
-
await api(`/session/${sessionId}/abort`, { method: 'POST', body: '{}' }, 5000);
|
|
487
|
-
} catch {}
|
|
488
|
-
ctrl.abort();
|
|
489
|
-
done = true;
|
|
490
|
-
}
|
|
491
|
-
}, 10_000);
|
|
492
|
-
|
|
493
|
-
try {
|
|
494
|
-
while (!done) {
|
|
495
|
-
const { done: streamDone, value } = await reader.read();
|
|
496
|
-
if (streamDone) break;
|
|
497
|
-
buf += decoder.decode(value, { stream: true });
|
|
498
|
-
const lines = buf.split('\n');
|
|
499
|
-
buf = lines.pop();
|
|
500
|
-
for (const line of lines) {
|
|
501
|
-
if (!line.startsWith('data:')) continue;
|
|
502
|
-
const raw = line.slice(5).trim();
|
|
503
|
-
if (!raw) continue;
|
|
504
|
-
let evt;
|
|
505
|
-
try {
|
|
506
|
-
evt = JSON.parse(raw);
|
|
507
|
-
} catch {
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
const p = evt.properties ?? {};
|
|
511
|
-
if (p.sessionID && p.sessionID !== sessionId) continue;
|
|
512
|
-
switch (evt.type) {
|
|
513
|
-
case 'message.part.delta': {
|
|
514
|
-
if (p.field === 'text' && typeof p.delta === 'string') {
|
|
515
|
-
if (p.partID && reasoningPartIDs.has(p.partID)) break;
|
|
516
|
-
lastProgress = Date.now();
|
|
517
|
-
streamingBuffer += p.delta;
|
|
518
|
-
// 达到块大小或句子边界即推送(块放大到 400,避免高频小消息被微信限流吞掉)
|
|
519
|
-
if (
|
|
520
|
-
streamingBuffer.length >= 400 ||
|
|
521
|
-
(/[。!?\n]/.test(streamingBuffer.slice(-2)) && streamingBuffer.length >= 40)
|
|
522
|
-
) {
|
|
523
|
-
await flushBuffer();
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
break;
|
|
527
|
-
}
|
|
528
|
-
case 'message.part.updated': {
|
|
529
|
-
const part = p.part ?? {};
|
|
530
|
-
if (part.type === 'reasoning' && part.id) {
|
|
531
|
-
reasoningPartIDs.add(part.id);
|
|
532
|
-
}
|
|
533
|
-
lastProgress = Date.now(); // 任何 part 更新都算进度
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
case 'session.status': {
|
|
537
|
-
const st = p.status ?? {};
|
|
538
|
-
if (st.type === 'idle') done = true;
|
|
539
|
-
break;
|
|
540
|
-
}
|
|
541
|
-
default:
|
|
542
|
-
break;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
} finally {
|
|
547
|
-
clearTimeout(timeout);
|
|
548
|
-
clearInterval(stallTimer);
|
|
549
|
-
ctrl.abort();
|
|
550
|
-
}
|
|
551
|
-
await flushBuffer();
|
|
552
|
-
return accumulated.trim();
|
|
428
|
+
const parts = resp?.parts || [];
|
|
429
|
+
const textParts = parts
|
|
430
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
431
|
+
.map((p) => p.text);
|
|
432
|
+
const answer = textParts.join('\n').trim();
|
|
433
|
+
if (answer) return answer;
|
|
434
|
+
// 回退:模型有时把结论只写在 reasoning 里,text 为空,此时用 reasoning 兜底
|
|
435
|
+
const reasoning = parts
|
|
436
|
+
.filter((p) => p.type === 'reasoning' && typeof p.text === 'string')
|
|
437
|
+
.map((p) => p.text)
|
|
438
|
+
.join('\n')
|
|
439
|
+
.trim();
|
|
440
|
+
return reasoning;
|
|
553
441
|
}
|
|
554
442
|
|
|
555
443
|
// ─── 微信消息处理 ─────────────────────────────────────
|
|
@@ -682,17 +570,7 @@ async function handleMessage(msg) {
|
|
|
682
570
|
|
|
683
571
|
let fullReply = '';
|
|
684
572
|
try {
|
|
685
|
-
fullReply = await
|
|
686
|
-
// 实时推送:边生成边发
|
|
687
|
-
const plainText = stripMarkdown(chunk);
|
|
688
|
-
if (!plainText.trim()) return;
|
|
689
|
-
const chunks = chunkText(plainText, 3900);
|
|
690
|
-
for (const c of chunks) {
|
|
691
|
-
const ok = await sendWechat(fromUser, c, contextToken, messageId);
|
|
692
|
-
if (ok) log(`流式已发送 from=${fromUser} len=${c.length}`);
|
|
693
|
-
await sleep(1000); // 发送间隔,避免高频触发微信限流
|
|
694
|
-
}
|
|
695
|
-
});
|
|
573
|
+
fullReply = await askOpenCode(sessionId, text);
|
|
696
574
|
} catch (err) {
|
|
697
575
|
log(`调用 opencode 失败: ${String(err)}`);
|
|
698
576
|
fullReply = `[处理失败: ${String(err.message ?? err)}]`;
|
|
@@ -700,19 +578,39 @@ async function handleMessage(msg) {
|
|
|
700
578
|
|
|
701
579
|
await sendTypingIndicator(fromUser, contextToken, 2);
|
|
702
580
|
|
|
703
|
-
//
|
|
581
|
+
// 处理 schedule 标记,再发送媒体
|
|
704
582
|
if (fullReply) {
|
|
705
583
|
let textToSend = processScheduleMarks(fullReply, fromUser);
|
|
584
|
+
let mediaSent = 0;
|
|
706
585
|
try {
|
|
707
|
-
const
|
|
708
|
-
textToSend = text;
|
|
586
|
+
const r = await sendMediaMarks(fromUser, contextToken, textToSend);
|
|
587
|
+
textToSend = r.text;
|
|
588
|
+
mediaSent = r.mediaSent;
|
|
709
589
|
if (mediaSent > 0) log(`已发送 ${mediaSent} 个媒体文件 from=${fromUser}`);
|
|
710
590
|
} catch (err) {
|
|
711
591
|
log(`媒体处理失败: ${String(err)}`);
|
|
712
592
|
}
|
|
713
|
-
|
|
714
|
-
|
|
593
|
+
// 一次性分段发送
|
|
594
|
+
const plainText = textToSend.trim() ? stripMarkdown(textToSend) : '';
|
|
595
|
+
if (plainText) {
|
|
596
|
+
const chunks = chunkText(plainText, 3900);
|
|
597
|
+
for (const c of chunks) {
|
|
598
|
+
const ok = await sendWechat(fromUser, c, contextToken, messageId);
|
|
599
|
+
if (ok) log(`已发送 from=${fromUser} len=${c.length}`);
|
|
600
|
+
await sleep(1000); // 发送间隔,避免高频触发微信限流
|
|
601
|
+
}
|
|
602
|
+
log(`已回复 from=${fromUser} chunks=${chunks.length}`);
|
|
603
|
+
} else if (mediaSent > 0) {
|
|
604
|
+
log(`已回复(仅媒体) from=${fromUser}`);
|
|
605
|
+
} else {
|
|
606
|
+
// AI 既无文本也无媒体,发提示避免静默无响应
|
|
607
|
+
await sendWechat(fromUser, '(我这边没有生成有效回复,请再试一次或换个说法)', contextToken, messageId);
|
|
608
|
+
log(`已回复(空回复兜底) from=${fromUser}`);
|
|
715
609
|
}
|
|
610
|
+
} else {
|
|
611
|
+
// 完全空回复(模型只思考未产出文本)
|
|
612
|
+
await sendWechat(fromUser, '(我这边没有生成有效回复,请再试一次或换个说法)', contextToken, messageId);
|
|
613
|
+
log(`已回复(空回复兜底) from=${fromUser}`);
|
|
716
614
|
}
|
|
717
615
|
}
|
|
718
616
|
|
package/src/cli.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
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
|
|
16
|
+
import { join } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import {
|
|
19
19
|
SERVER_BASE,
|
|
@@ -57,6 +57,20 @@ function resolveOpenCodeBin() {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 补全 PATH:launchd/计划任务环境下 PATH 常缺 homebrew 等目录,
|
|
62
|
+
* 会导致 AI 在 serve 里执行 `which ffmpeg` 等找不到工具。
|
|
63
|
+
* 把常见工具目录加入 PATH 最前面。
|
|
64
|
+
*/
|
|
65
|
+
function buildPath() {
|
|
66
|
+
const sep = isWin ? ';' : ':';
|
|
67
|
+
const extra = isWin
|
|
68
|
+
? [join(homedir(), 'AppData', 'Local', 'Microsoft', 'WinGet', 'Links')]
|
|
69
|
+
: ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.local', 'bin')];
|
|
70
|
+
const cur = (process.env.PATH || '').split(sep);
|
|
71
|
+
return [...new Set([...extra, ...cur])].filter(Boolean).join(sep);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function usage() {
|
|
61
75
|
console.log(`opencode-wechat-bot
|
|
62
76
|
用法:
|
|
@@ -90,6 +104,7 @@ async function startServe() {
|
|
|
90
104
|
// 禁用 wechat MCP,避免与 bridge 抢轮询;可选鉴权
|
|
91
105
|
const env = {
|
|
92
106
|
...process.env,
|
|
107
|
+
PATH: buildPath(),
|
|
93
108
|
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
94
109
|
};
|
|
95
110
|
console.log(`[serve] 启动 ${opencodeBin} ${args.join(' ')}`);
|
|
@@ -184,13 +199,7 @@ async function installService() {
|
|
|
184
199
|
['serve'],
|
|
185
200
|
{
|
|
186
201
|
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
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(':'),
|
|
202
|
+
PATH: buildPath(),
|
|
194
203
|
},
|
|
195
204
|
),
|
|
196
205
|
bridge: makePlist(
|
|
@@ -198,13 +207,7 @@ async function installService() {
|
|
|
198
207
|
['bridge'],
|
|
199
208
|
{
|
|
200
209
|
WECHAT_BOT_HOME: BOT_HOME,
|
|
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(':'),
|
|
210
|
+
PATH: buildPath(),
|
|
208
211
|
},
|
|
209
212
|
),
|
|
210
213
|
};
|