wechat-opencode-bot 0.2.1 → 0.2.3

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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/package.json +1 -1
  3. package/src/bridge.js +61 -11
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wechat-opencode-bot",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "微信 ⇄ opencode 桥接机器人:微信消息通过 opencode serve 驱动 AI 回复,支持定时任务主动推送、媒体收发,跨平台(macOS/Windows/Linux)。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bridge.js CHANGED
@@ -23,7 +23,7 @@ const CC_WECHAT_DIR = findCcWechatDir();
23
23
  const { getActiveAccount, loadSyncBuf, saveSyncBuf } = await import(
24
24
  join(CC_WECHAT_DIR, 'dist', 'store.js')
25
25
  );
26
- const { getUpdates, sendMessage, sendTyping, getConfig } = await import(
26
+ const { getUpdates, sendMessage, sendTyping, getConfig, buildBaseInfo: ccBuildBaseInfo, buildHeaders: ccBuildHeaders } = await import(
27
27
  join(CC_WECHAT_DIR, 'dist', 'ilink-api.js')
28
28
  );
29
29
  const { stripMarkdown, chunkText } = await import(
@@ -222,7 +222,8 @@ async function fireTask(task) {
222
222
  const plainText = stripMarkdown(chunk);
223
223
  if (!plainText.trim()) return;
224
224
  for (const c of chunkText(plainText, 3900)) {
225
- await sendMessage(account.token, task.userId, c, '', account.baseUrl, undefined);
225
+ await sendWechat(task.userId, c, '', undefined);
226
+ await sleep(1000);
226
227
  }
227
228
  });
228
229
  } catch (err) {
@@ -324,6 +325,56 @@ async function api(path, options = {}, timeoutMs = 30_000) {
324
325
  }
325
326
  }
326
327
 
328
+ /**
329
+ * 发送微信消息并校验业务返回码(cc-wechat 的 sendMessage 只查 HTTP 状态,
330
+ * 微信限流时返回 HTTP 200 + ret!=0 会被静默吞掉,导致"回一半")。
331
+ * 失败自动重试(指数退避)。返回 true/false。
332
+ */
333
+ async function sendWechat(userId, text, contextToken, refMsgId, { retries = 5 } = {}) {
334
+ const { randomBytes } = await import('node:crypto');
335
+ const clientId = `cc-wechat-${randomBytes(4).toString('hex')}`;
336
+ const textItem = { type: 1, text_item: { text } };
337
+ if (refMsgId) textItem.ref_msg = { title: text.slice(0, 40) };
338
+ const msg = {
339
+ from_user_id: '',
340
+ to_user_id: userId,
341
+ client_id: clientId,
342
+ message_type: 2,
343
+ message_state: 2,
344
+ item_list: [textItem],
345
+ context_token: contextToken,
346
+ };
347
+ if (refMsgId) msg.ref_message_id = refMsgId;
348
+ const body = JSON.stringify({ msg, base_info: ccBuildBaseInfo() });
349
+ let lastErr = '';
350
+ for (let attempt = 1; attempt <= retries; attempt++) {
351
+ try {
352
+ const base = (account.baseUrl || '').replace(/\/+$/, '') + '/';
353
+ const resp = await fetch(`${base}ilink/bot/sendmessage`, {
354
+ method: 'POST',
355
+ headers: ccBuildHeaders(account.token, body),
356
+ body,
357
+ });
358
+ const text = await resp.text();
359
+ let parsed = null;
360
+ try { parsed = JSON.parse(text); } catch {}
361
+ const ret = parsed?.ret ?? parsed?.errcode;
362
+ if (parsed && ret !== undefined && ret !== 0) {
363
+ lastErr = `ret=${ret} ${parsed.errmsg ?? ''}`;
364
+ // 限流或失败,指数退避重试(1s/2s/4s/8s...)
365
+ await sleep(1000 * 2 ** (attempt - 1));
366
+ continue;
367
+ }
368
+ return true;
369
+ } catch (err) {
370
+ lastErr = String(err.message ?? err);
371
+ await sleep(1000 * 2 ** (attempt - 1));
372
+ }
373
+ }
374
+ log(`sendWechat 失败 ${userId}: ${lastErr}`);
375
+ return false;
376
+ }
377
+
327
378
  /** 获取或创建该微信用户的 opencode session */
328
379
  async function getSessionForUser(userId) {
329
380
  if (sessionMap[userId]) {
@@ -357,7 +408,8 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
357
408
  [schedule_list] 查看当前定时任务
358
409
  [schedule_del: 任务ID] 删除指定定时任务
359
410
  到点后系统会调用你生成内容并主动推送给用户。
360
- 回复要求: 请给出完整、详细、原样的回答,把你在命令行中会输出的全部内容直接发过来,不要缩略、不要只给总结或要点提示,确保用户看到完整答案。如果执行了代码或命令,把关键输出也一并附上。`;
411
+ 回复要求: 请给出完整、详细、原样的回答,把你在命令行中会输出的全部内容直接发过来,不要缩略、不要只给总结或要点提示,确保用户看到完整答案。如果执行了代码或命令,把关键输出也一并附上。
412
+ 微信格式: 不要使用 ASCII 艺术表格(┌─┬─┐ box-drawing 字符),微信会显示错乱。表格/列表改用纯文本(如 "编号 - 标题 (状态)"、"- 项目"、用缩进和符号组织),代码块用围栏标记即可。`;
361
413
 
362
414
  // 确认 session 存在
363
415
  await api(`/session/${sessionId}`, {}, 5000);
@@ -463,10 +515,10 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
463
515
  if (p.partID && reasoningPartIDs.has(p.partID)) break;
464
516
  lastProgress = Date.now();
465
517
  streamingBuffer += p.delta;
466
- // 达到块大小或句子边界即推送
518
+ // 达到块大小或句子边界即推送(块放大到 400,避免高频小消息被微信限流吞掉)
467
519
  if (
468
- streamingBuffer.length >= 60 ||
469
- /[。!?\n]/.test(streamingBuffer.slice(-2))
520
+ streamingBuffer.length >= 400 ||
521
+ (/[。!?\n]/.test(streamingBuffer.slice(-2)) && streamingBuffer.length >= 40)
470
522
  ) {
471
523
  await flushBuffer();
472
524
  }
@@ -636,11 +688,9 @@ async function handleMessage(msg) {
636
688
  if (!plainText.trim()) return;
637
689
  const chunks = chunkText(plainText, 3900);
638
690
  for (const c of chunks) {
639
- try {
640
- await sendMessage(account.token, fromUser, c, contextToken, account.baseUrl, messageId);
641
- } catch (err) {
642
- log(`发送失败: ${String(err)}`);
643
- }
691
+ const ok = await sendWechat(fromUser, c, contextToken, messageId);
692
+ if (ok) log(`流式已发送 from=${fromUser} len=${c.length}`);
693
+ await sleep(1000); // 发送间隔,避免高频触发微信限流
644
694
  }
645
695
  });
646
696
  } catch (err) {