wechat-opencode-bot 0.2.1 → 0.2.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/bridge.js +61 -11
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.2",
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) {