wechat-opencode-bot 0.2.2 → 0.2.4
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 +9 -1
- package/package.json +1 -1
- package/src/bridge.js +34 -157
package/README.md
CHANGED
|
@@ -31,11 +31,16 @@ wechat-bot status # 查看状态
|
|
|
31
31
|
或使用自启动:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
wechat-bot install # macOS 生成 launchd plist
|
|
34
|
+
wechat-bot install # macOS: 生成 launchd plist 到 ~/Library/LaunchAgents
|
|
35
|
+
# 然后加载(一次性):
|
|
36
|
+
launchctl load ~/Library/LaunchAgents/com.opencode-wechat-bot.serve.plist
|
|
37
|
+
launchctl load ~/Library/LaunchAgents/com.opencode-wechat-bot.bridge.plist
|
|
35
38
|
./bin/start.sh start # 或手动启动脚本(macOS/Linux)
|
|
36
39
|
./bin/start.bat start# Windows
|
|
37
40
|
```
|
|
38
41
|
|
|
42
|
+
> 注:`install` 生成的 plist 含 `RunAtLoad` + `KeepAlive`,开机自启 + 崩溃自动重启。若 `opencode` 不在 PATH,`install` 会自动探测并把其目录加入 plist 的 PATH。
|
|
43
|
+
|
|
39
44
|
## 环境变量
|
|
40
45
|
|
|
41
46
|
| 变量 | 默认值 | 说明 |
|
|
@@ -52,10 +57,13 @@ wechat-bot install # macOS 生成 launchd plist 并加载
|
|
|
52
57
|
## 功能
|
|
53
58
|
|
|
54
59
|
- **自动回复**:每用户独立 opencode session(会话历史持久化)。
|
|
60
|
+
- **流式回复**:AI 边生成边分段推送到微信(块约 400 字,句号/换行处断句)。
|
|
61
|
+
- **可靠发送**:校验微信业务返回码(`ret!=0` 自动指数退避重试),发送间隔 1s 避免高频触发微信限流。
|
|
55
62
|
- **定时任务**:对 bot 说"每天早上9点提醒我喝水",AI 输出 `[schedule: 09:00 | 提醒喝水]` 标记,bridge 解析存储,到点主动推送。
|
|
56
63
|
- 时间表达式:`HH:MM`(每天) / `每N小时` / `每N分钟` / `YYYY-MM-DD HH:MM`(一次性)
|
|
57
64
|
- 查看/删除:`[schedule_list]` / `[schedule_del: id]`
|
|
58
65
|
- **图片/文件**:接收时下载到本地供 AI 读取(`[图片: 路径]`);发送时用 `[media: /绝对/路径]` 标记上传。
|
|
66
|
+
- **微信友好格式**:系统提示要求 AI 不用 ASCII 表格(box-drawing 字符),改纯文本列表,避免微信显示乱码。
|
|
59
67
|
- **自动重连**:网络错误指数退避,session 过期自动提示重新登录。
|
|
60
68
|
|
|
61
69
|
## 权限配置
|
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
|
定时任务指令(用户要求定时提醒/推送时使用):
|
|
@@ -414,142 +413,20 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
414
413
|
// 确认 session 存在
|
|
415
414
|
await api(`/session/${sessionId}`, {}, 5000);
|
|
416
415
|
|
|
417
|
-
//
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
// 2. 异步发送消息(204 立即返回)
|
|
421
|
-
await api(
|
|
422
|
-
`/session/${sessionId}/prompt_async`,
|
|
416
|
+
// 同步发送并等待完整回复
|
|
417
|
+
const resp = await api(
|
|
418
|
+
`/session/${sessionId}/message`,
|
|
423
419
|
{
|
|
424
420
|
method: 'POST',
|
|
425
421
|
body: JSON.stringify({ parts: [{ type: 'text', text: `${SYSTEM_HINT}\n\n微信消息:\n${text}` }] }),
|
|
426
422
|
},
|
|
427
|
-
|
|
423
|
+
MAX_LLM_RESPONSE_TIME_MS,
|
|
428
424
|
);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const resp = await eventFetch;
|
|
436
|
-
if (!resp.ok || !resp.body) {
|
|
437
|
-
throw new Error(`SSE 连接失败: HTTP ${resp.status}`);
|
|
438
|
-
}
|
|
439
|
-
const reader = resp.body.getReader();
|
|
440
|
-
const decoder = new TextDecoder();
|
|
441
|
-
let buf = '';
|
|
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();
|
|
425
|
+
const parts = resp?.parts || [];
|
|
426
|
+
const textParts = parts
|
|
427
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
428
|
+
.map((p) => p.text);
|
|
429
|
+
return textParts.join('\n').trim();
|
|
553
430
|
}
|
|
554
431
|
|
|
555
432
|
// ─── 微信消息处理 ─────────────────────────────────────
|
|
@@ -682,17 +559,7 @@ async function handleMessage(msg) {
|
|
|
682
559
|
|
|
683
560
|
let fullReply = '';
|
|
684
561
|
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
|
-
});
|
|
562
|
+
fullReply = await askOpenCode(sessionId, text);
|
|
696
563
|
} catch (err) {
|
|
697
564
|
log(`调用 opencode 失败: ${String(err)}`);
|
|
698
565
|
fullReply = `[处理失败: ${String(err.message ?? err)}]`;
|
|
@@ -700,7 +567,7 @@ async function handleMessage(msg) {
|
|
|
700
567
|
|
|
701
568
|
await sendTypingIndicator(fromUser, contextToken, 2);
|
|
702
569
|
|
|
703
|
-
//
|
|
570
|
+
// 处理 schedule 标记,再发送媒体
|
|
704
571
|
if (fullReply) {
|
|
705
572
|
let textToSend = processScheduleMarks(fullReply, fromUser);
|
|
706
573
|
try {
|
|
@@ -710,8 +577,18 @@ async function handleMessage(msg) {
|
|
|
710
577
|
} catch (err) {
|
|
711
578
|
log(`媒体处理失败: ${String(err)}`);
|
|
712
579
|
}
|
|
580
|
+
// 一次性分段发送
|
|
713
581
|
if (textToSend.trim()) {
|
|
714
|
-
|
|
582
|
+
const plainText = stripMarkdown(textToSend);
|
|
583
|
+
const chunks = chunkText(plainText, 3900);
|
|
584
|
+
for (const c of chunks) {
|
|
585
|
+
const ok = await sendWechat(fromUser, c, contextToken, messageId);
|
|
586
|
+
if (ok) log(`已发送 from=${fromUser} len=${c.length}`);
|
|
587
|
+
await sleep(1000); // 发送间隔,避免高频触发微信限流
|
|
588
|
+
}
|
|
589
|
+
log(`已回复 from=${fromUser} chunks=${chunks.length}`);
|
|
590
|
+
} else {
|
|
591
|
+
log(`已回复(仅媒体) from=${fromUser}`);
|
|
715
592
|
}
|
|
716
593
|
}
|
|
717
594
|
}
|