wechat-opencode-bot 0.2.0 → 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.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/bridge.js +74 -13
package/README.md
CHANGED
|
@@ -58,6 +58,27 @@ wechat-bot install # macOS 生成 launchd plist 并加载
|
|
|
58
58
|
- **图片/文件**:接收时下载到本地供 AI 读取(`[图片: 路径]`);发送时用 `[media: /绝对/路径]` 标记上传。
|
|
59
59
|
- **自动重连**:网络错误指数退避,session 过期自动提示重新登录。
|
|
60
60
|
|
|
61
|
+
## 权限配置
|
|
62
|
+
|
|
63
|
+
opencode serve 为无人值守环境。建议在 opencode 配置(`~/.config/opencode/opencode.json`)中设置权限,让工具自动执行而不触发权限确认(权限确认在无头环境会卡住会话):
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"permission": {
|
|
68
|
+
"bash": { "*": "allow" },
|
|
69
|
+
"read": { "*": "allow" },
|
|
70
|
+
"write": { "*": "allow" },
|
|
71
|
+
"edit": { "*": "allow" },
|
|
72
|
+
"skill": { "*": "deny" },
|
|
73
|
+
"question": "allow",
|
|
74
|
+
"webfetch": "allow"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- `bash`/`read` 等放行:微信里可直接让 AI 执行 `tea`、`git` 等命令并读取文件
|
|
80
|
+
- `skill` 设为 `deny`:skill 工具在 serve 环境可能不触发事件结束(卡住会话),建议禁止
|
|
81
|
+
|
|
61
82
|
## 数据文件
|
|
62
83
|
|
|
63
84
|
- `sessions.json`:微信用户 → opencode session 映射
|
package/package.json
CHANGED
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
|
|
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]) {
|
|
@@ -358,7 +409,7 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
358
409
|
[schedule_del: 任务ID] 删除指定定时任务
|
|
359
410
|
到点后系统会调用你生成内容并主动推送给用户。
|
|
360
411
|
回复要求: 请给出完整、详细、原样的回答,把你在命令行中会输出的全部内容直接发过来,不要缩略、不要只给总结或要点提示,确保用户看到完整答案。如果执行了代码或命令,把关键输出也一并附上。
|
|
361
|
-
|
|
412
|
+
微信格式: 不要使用 ASCII 艺术表格(┌─┬─┐ box-drawing 字符),微信会显示错乱。表格/列表改用纯文本(如 "编号 - 标题 (状态)"、"- 项目"、用缩进和符号组织),代码块用围栏标记即可。`;
|
|
362
413
|
|
|
363
414
|
// 确认 session 存在
|
|
364
415
|
await api(`/session/${sessionId}`, {}, 5000);
|
|
@@ -413,10 +464,22 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
413
464
|
}
|
|
414
465
|
}, MAX_LLM_RESPONSE_TIME_MS);
|
|
415
466
|
|
|
416
|
-
//
|
|
467
|
+
// 兜底检测:SSE 可能漏发 idle 事件(工具执行后),定期轮询 session 状态判断是否真正结束;
|
|
468
|
+
// 长时间无任何新 part 则视为卡死,abort session 避免永久 busy
|
|
417
469
|
let lastProgress = Date.now();
|
|
418
470
|
const stallTimer = setInterval(async () => {
|
|
419
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 {}
|
|
420
483
|
if (Date.now() - lastProgress > 90_000) {
|
|
421
484
|
log(`流式无进度超时(90s),abort session ${sessionId}`);
|
|
422
485
|
try {
|
|
@@ -425,7 +488,7 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
425
488
|
ctrl.abort();
|
|
426
489
|
done = true;
|
|
427
490
|
}
|
|
428
|
-
},
|
|
491
|
+
}, 10_000);
|
|
429
492
|
|
|
430
493
|
try {
|
|
431
494
|
while (!done) {
|
|
@@ -452,10 +515,10 @@ async function askOpenCodeStream(sessionId, text, onChunk) {
|
|
|
452
515
|
if (p.partID && reasoningPartIDs.has(p.partID)) break;
|
|
453
516
|
lastProgress = Date.now();
|
|
454
517
|
streamingBuffer += p.delta;
|
|
455
|
-
//
|
|
518
|
+
// 达到块大小或句子边界即推送(块放大到 400,避免高频小消息被微信限流吞掉)
|
|
456
519
|
if (
|
|
457
|
-
streamingBuffer.length >=
|
|
458
|
-
/[。!?\n]/.test(streamingBuffer.slice(-2))
|
|
520
|
+
streamingBuffer.length >= 400 ||
|
|
521
|
+
(/[。!?\n]/.test(streamingBuffer.slice(-2)) && streamingBuffer.length >= 40)
|
|
459
522
|
) {
|
|
460
523
|
await flushBuffer();
|
|
461
524
|
}
|
|
@@ -625,11 +688,9 @@ async function handleMessage(msg) {
|
|
|
625
688
|
if (!plainText.trim()) return;
|
|
626
689
|
const chunks = chunkText(plainText, 3900);
|
|
627
690
|
for (const c of chunks) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
log(`发送失败: ${String(err)}`);
|
|
632
|
-
}
|
|
691
|
+
const ok = await sendWechat(fromUser, c, contextToken, messageId);
|
|
692
|
+
if (ok) log(`流式已发送 from=${fromUser} len=${c.length}`);
|
|
693
|
+
await sleep(1000); // 发送间隔,避免高频触发微信限流
|
|
633
694
|
}
|
|
634
695
|
});
|
|
635
696
|
} catch (err) {
|