@raolin2025/claude-code-node 2.7.6 → 2.7.8
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/package.json +1 -1
- package/src/__tests__/tg-listener-flush.test.js +92 -0
- package/src/channel/tg-listener.js +50 -1
- package/src/channel/tg-proxy.js +17 -0
- package/src/core/cli.js +13 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.8",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/core/index.js",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { TelegramListener } from '../channel/tg-listener.js'
|
|
4
|
+
|
|
5
|
+
const tick = (ms = 10) => new Promise((r) => setTimeout(r, ms))
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 构造一个用 mock _fetch 的 TelegramListener,用于验证 flushOffset 的排他锁逻辑。
|
|
9
|
+
* 为避免 _poll 无限循环导致测试进程无法退出,测试通过直接设置挂起状态来驱动。
|
|
10
|
+
*/
|
|
11
|
+
function makeListener() {
|
|
12
|
+
const listener = new TelegramListener({ channels: { telegram: { token: 'test-token' } } })
|
|
13
|
+
listener._loadOffset = () => 0
|
|
14
|
+
listener._saveOffset = () => {}
|
|
15
|
+
|
|
16
|
+
const calls = []
|
|
17
|
+
// 模拟一个可被 abort 的挂起请求(等价于 _poll 正在长轮询挂起)
|
|
18
|
+
listener._fetch = async (url, options = {}) => {
|
|
19
|
+
const body = JSON.parse(options.body || '{}')
|
|
20
|
+
calls.push({ tgTimeout: body.timeout, httpTimeout: options.timeout })
|
|
21
|
+
|
|
22
|
+
// Telegram 长轮询 = body.timeout > 0(如 _poll 传 30);flushOffset 传 0 立即返回。
|
|
23
|
+
if ((body.timeout || 0) > 0) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
if (options.signal) {
|
|
26
|
+
options.signal.addEventListener('abort', () => {
|
|
27
|
+
const e = new Error('aborted')
|
|
28
|
+
e.name = 'AbortError'
|
|
29
|
+
reject(e)
|
|
30
|
+
}, { once: true })
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, json: async () => ({ ok: true, result: [] }) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { listener, calls }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('flushOffset 会取消挂起的 _poll 长轮询并独占连接(消除 409 竞态)', async () => {
|
|
41
|
+
const { listener, calls } = makeListener()
|
|
42
|
+
|
|
43
|
+
// 模拟 _poll 正处于长轮询挂起:设置 _polling 与一个可 abort 的 AbortController
|
|
44
|
+
const pollAbort = new AbortController()
|
|
45
|
+
listener._polling = true
|
|
46
|
+
listener._pollAbort = pollAbort
|
|
47
|
+
// 让 mock 的挂起请求真正绑定到该 signal(模拟 _poll 正在 await 它)
|
|
48
|
+
const pendingReq = listener._fetch(`${listener.bot.apiBase}/getUpdates`, {
|
|
49
|
+
method: 'POST', headers: {}, timeout: 60000, signal: pollAbort.signal,
|
|
50
|
+
body: JSON.stringify({ offset: 1, timeout: 30 }),
|
|
51
|
+
})
|
|
52
|
+
// 预先消费可能的拒绝(flush 会 abort 它),避免 unhandled rejection 被记为测试失败
|
|
53
|
+
const pendingReqSettled = pendingReq.catch(() => {})
|
|
54
|
+
|
|
55
|
+
// 调用 flushOffset(模拟 /quit 前的确认消费)
|
|
56
|
+
const flushPromise = listener.flushOffset()
|
|
57
|
+
await tick(20)
|
|
58
|
+
|
|
59
|
+
// flush 应立即 abort 掉挂起的 poll 请求
|
|
60
|
+
assert.strictEqual(pollAbort.signal.aborted, true, 'flushOffset 应 abort 挂起的 _poll 长轮询')
|
|
61
|
+
// 等待挂起请求因 abort 结束
|
|
62
|
+
await pendingReqSettled
|
|
63
|
+
// flushOffset 自身不应直接修改 _polling(由 _poll 的 finally 清理)
|
|
64
|
+
assert.strictEqual(listener._polling, true, 'flush 不应直接改 _polling(由 _poll 的 finally 清理)')
|
|
65
|
+
// 模拟真实 _poll 的 finally:请求结束后立即清理 _polling(真实 _poll 会这样做),
|
|
66
|
+
// 使 flushOffset 的等待循环立即退出,贴近真实时序并避免 2s deadline。
|
|
67
|
+
listener._polling = false
|
|
68
|
+
await flushPromise
|
|
69
|
+
|
|
70
|
+
assert.strictEqual(listener._flushLock, false, 'flush 应释放排他锁')
|
|
71
|
+
|
|
72
|
+
// flush 应发起 body.timeout:0 的 getUpdates(确认消费),且不与他人并发
|
|
73
|
+
const flushCall = calls.find((c) => c.tgTimeout === 0)
|
|
74
|
+
assert.strictEqual(flushCall !== undefined, true, 'flushOffset 应发起 timeout:0 的 getUpdates')
|
|
75
|
+
// 恰好 2 次请求:1 次挂起的长轮询 + 1 次 flush(无并发重复请求)
|
|
76
|
+
assert.strictEqual(calls.length, 2, '不应有多余的并发 getUpdates')
|
|
77
|
+
|
|
78
|
+
listener.conversations.destroy() // 清理 setInterval 定时器,避免测试进程残留
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('flushOffset 未挂起长轮询时也能正常 flush', async () => {
|
|
82
|
+
const { listener, calls } = makeListener()
|
|
83
|
+
|
|
84
|
+
await listener.flushOffset()
|
|
85
|
+
|
|
86
|
+
const flushCall = calls.find((c) => c.tgTimeout === 0)
|
|
87
|
+
assert.strictEqual(flushCall !== undefined, true, 'flushOffset 应发起确认消费请求')
|
|
88
|
+
assert.strictEqual(calls.length, 1, '仅 1 次请求(flush)')
|
|
89
|
+
assert.strictEqual(listener._flushLock, false, 'flush 应释放排他锁')
|
|
90
|
+
|
|
91
|
+
listener.conversations.destroy() // 清理 setInterval 定时器,避免测试进程残留
|
|
92
|
+
})
|
|
@@ -284,6 +284,11 @@ export class TelegramListener {
|
|
|
284
284
|
this._lastPollError = null // 最近一次轮询错误消息(用于去抖刷屏)
|
|
285
285
|
this._lastPollErrorAt = 0 // 最近一次轮询错误时间戳
|
|
286
286
|
this._msgQueue = Promise.resolve() // 消息串行处理队列(避免阻塞轮询循环)
|
|
287
|
+
// 排他锁 — 解决 flushOffset 与 _poll 长轮询并发触发 getUpdates 导致 409 Conflict。
|
|
288
|
+
// Telegram 同一 bot token 同一时刻只允许一个 getUpdates 长轮询。
|
|
289
|
+
this._pollAbort = null // 当前 _poll 挂起请求的 AbortController(供 flush 取消)
|
|
290
|
+
this._flushLock = false // true = flushOffset 正在独占 bot 连接
|
|
291
|
+
this._polling = false // 是否正处于 _poll 请求挂起中(await 未返回)
|
|
287
292
|
this.conversations = new ConversationState()
|
|
288
293
|
this._onMessage = null
|
|
289
294
|
this._handlers = {}
|
|
@@ -329,11 +334,23 @@ export class TelegramListener {
|
|
|
329
334
|
/** 内部轮询 */
|
|
330
335
|
async _poll() {
|
|
331
336
|
while (this.running) {
|
|
337
|
+
// flushOffset 持有排他锁时,不发起新的 getUpdates,避免与 flush 并发导致 409。
|
|
338
|
+
// flush 会 abort 当前挂起请求并设置锁,这里等待锁释放后再继续。
|
|
339
|
+
if (this._flushLock) {
|
|
340
|
+
await this._sleep(50)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 为本次请求创建 AbortController,flushOffset 可通过它取消挂起的长轮询,
|
|
345
|
+
// 从而独占 bot 连接、消除 409 竞态。
|
|
346
|
+
this._pollAbort = new AbortController()
|
|
347
|
+
this._polling = true
|
|
332
348
|
try {
|
|
333
349
|
const url = `${this.bot.apiBase}/getUpdates`
|
|
334
350
|
const res = await this._fetch(url, {
|
|
335
351
|
method: 'POST',
|
|
336
352
|
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
signal: this._pollAbort.signal,
|
|
337
354
|
// 长轮询本身最多等 30s(Telegram API 参数),HTTP 层超时必须更长,
|
|
338
355
|
// 否则代理路径(fetchViaSocks5)30s 硬超时会误杀长轮询 → HTTP request timeout 刷屏。
|
|
339
356
|
timeout: 60000,
|
|
@@ -379,6 +396,11 @@ export class TelegramListener {
|
|
|
379
396
|
this._retryDelay = 1000
|
|
380
397
|
|
|
381
398
|
} catch (e) {
|
|
399
|
+
// flushOffset 通过 abort 取消挂起请求以独占连接 — 属于预期中断,静默处理不告警。
|
|
400
|
+
// 回到循环顶部,等待 flushOffset 释放 _flushLock 后继续轮询。
|
|
401
|
+
if (this._flushLock && e.name === 'AbortError') {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
382
404
|
// 错误去抖:连续相同的错误(如长轮询超时)只在首次/状态变化时打印,
|
|
383
405
|
// 避免 AI 处理长任务时 getUpdates 空转超时不断刷屏。
|
|
384
406
|
const msg = e.message || 'unknown'
|
|
@@ -392,6 +414,10 @@ export class TelegramListener {
|
|
|
392
414
|
}
|
|
393
415
|
await this._sleep(this._retryDelay)
|
|
394
416
|
this._retryDelay = Math.min(this._retryDelay * 2, this.maxRetryDelay)
|
|
417
|
+
} finally {
|
|
418
|
+
// 请求结束(成功/失败/abort)— 清理挂起状态,供 flushOffset 判断 poll 已让出
|
|
419
|
+
this._polling = false
|
|
420
|
+
this._pollAbort = null
|
|
395
421
|
}
|
|
396
422
|
}
|
|
397
423
|
}
|
|
@@ -694,10 +720,28 @@ export class TelegramListener {
|
|
|
694
720
|
* 同步强杀进程,没有机会再 poll 一次 → 未确认的 update 残留在服务器。
|
|
695
721
|
* 调用 getUpdates(offset=lastUpdateId+1) 可让 Telegram 立即确认消费。
|
|
696
722
|
* 返回 Promise,调用方应 await 后再退出。
|
|
723
|
+
*
|
|
724
|
+
* 加固:Telegram 同一 bot token 同时只允许一个 getUpdates 长轮询,
|
|
725
|
+
* 若 flush 与 _poll 正在挂起的长轮询并发,会触发 409 Conflict。
|
|
726
|
+
* 因此 flush 先通过 AbortController 取消当前 _poll 请求、独占 bot 连接,
|
|
727
|
+
* 再发起自己的 getUpdates,彻底消除 409 竞态。
|
|
697
728
|
*/
|
|
698
729
|
async flushOffset() {
|
|
699
730
|
if (!this.bot) return
|
|
731
|
+
// 已持有排他锁(理论上不会重入,防御性直接返回)
|
|
732
|
+
if (this._flushLock) return
|
|
733
|
+
|
|
734
|
+
// 1. 取得排他锁:先取消正在挂起的 _poll 长轮询,再等它完全让出连接。
|
|
735
|
+
this._flushLock = true
|
|
700
736
|
try {
|
|
737
|
+
if (this._pollAbort) this._pollAbort.abort()
|
|
738
|
+
// 等待 _poll 从 await 返回并清理 _polling(最长 2s,防止极端情况下卡死退出)
|
|
739
|
+
const deadline = Date.now() + 2000
|
|
740
|
+
while (this._polling && Date.now() < deadline) {
|
|
741
|
+
await this._sleep(20)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 2. 独占发起 getUpdates 确认消费(timeout:0 立即返回,不挂长轮询)
|
|
701
745
|
const url = `${this.bot.apiBase}/getUpdates`
|
|
702
746
|
const res = await this._fetch(url, {
|
|
703
747
|
method: 'POST',
|
|
@@ -716,7 +760,12 @@ export class TelegramListener {
|
|
|
716
760
|
return
|
|
717
761
|
}
|
|
718
762
|
}
|
|
719
|
-
} catch {
|
|
763
|
+
} catch {
|
|
764
|
+
// flush 失败不影响退出,offset 已持久化到磁盘,双保险兜底
|
|
765
|
+
} finally {
|
|
766
|
+
// 3. 释放排他锁,_poll 循环顶部检测到后继续轮询(进程随即退出,正常路径用不到)
|
|
767
|
+
this._flushLock = false
|
|
768
|
+
}
|
|
720
769
|
}
|
|
721
770
|
}
|
|
722
771
|
|
package/src/channel/tg-proxy.js
CHANGED
|
@@ -279,12 +279,28 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
|
|
|
279
279
|
reject(new Error('HTTP request timeout'))
|
|
280
280
|
}, timeoutMs)
|
|
281
281
|
|
|
282
|
+
// 支持 AbortController 取消(如 flushOffset 想中断挂起的长轮询以独占 bot 连接)。
|
|
283
|
+
// 触发 abort 时销毁 socket,使挂起的请求立即以 AbortError 结束,而不是等 HTTP 超时。
|
|
284
|
+
const signal = options.signal
|
|
285
|
+
const onAbort = () => {
|
|
286
|
+
const err = new Error('The operation was aborted')
|
|
287
|
+
err.name = 'AbortError'
|
|
288
|
+
clearTimeout(timeout)
|
|
289
|
+
socket.destroy()
|
|
290
|
+
reject(err)
|
|
291
|
+
}
|
|
292
|
+
if (signal) {
|
|
293
|
+
if (signal.aborted) return onAbort()
|
|
294
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
295
|
+
}
|
|
296
|
+
|
|
282
297
|
socket.write(reqBuf)
|
|
283
298
|
socket.on('data', (chunk) => {
|
|
284
299
|
responseData += chunk.toString()
|
|
285
300
|
})
|
|
286
301
|
socket.on('end', () => {
|
|
287
302
|
clearTimeout(timeout)
|
|
303
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
288
304
|
// 解析 HTTP 响应
|
|
289
305
|
const headerEnd = responseData.indexOf('\r\n\r\n')
|
|
290
306
|
if (headerEnd < 0) {
|
|
@@ -306,6 +322,7 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
|
|
|
306
322
|
})
|
|
307
323
|
socket.on('error', (err) => {
|
|
308
324
|
clearTimeout(timeout)
|
|
325
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
309
326
|
reject(err)
|
|
310
327
|
})
|
|
311
328
|
})
|
package/src/core/cli.js
CHANGED
|
@@ -588,10 +588,6 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
588
588
|
let currentSource = 'cli'
|
|
589
589
|
// 等待中的 Telegram 权限确认(resolve 回调)— 远程用户回复 y/n/a 时响应
|
|
590
590
|
let pendingConfirm = null
|
|
591
|
-
// 等待中的 Telegram AskUserQuestion 回答(resolve 回调)— 远程用户回复任意文本时响应
|
|
592
|
-
let pendingAskUser = null
|
|
593
|
-
// 取消当前等待中的 AskUserQuestion(用户 /stop 时调用,resolve 为取消标记,避免死锁)
|
|
594
|
-
let cancelAskUser = null
|
|
595
591
|
// 最近一次 /models 拉取的模型列表(供 /model <编号> 选择)
|
|
596
592
|
let modelList = []
|
|
597
593
|
// /models 从 Telegram 发出后,等待用户回复编号/名字来选择模型
|
|
@@ -611,14 +607,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
611
607
|
// 关键:来自 Telegram 的消息,如果当前正在等待远程权限确认(pendingConfirm),
|
|
612
608
|
// 则把它当作确认回复(y/n/a)处理,而不是当作新的 REPL 输入。
|
|
613
609
|
// 注意:/ 开头的命令优先走命令分支,不被权限确认拦截(否则 /quit、/stop 会被吞)。
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
const ask = pendingAskUser
|
|
617
|
-
pendingAskUser = null
|
|
618
|
-
cancelAskUser = null
|
|
619
|
-
ask.fn(trimmed)
|
|
620
|
-
return
|
|
621
|
-
}
|
|
610
|
+
// (AskUserQuestion 已改为异步发问,不再依赖 pendingAskUser 挂起结构——用户回复
|
|
611
|
+
// 作为正常消息进入引擎处理,无锁死/发呆问题。)
|
|
622
612
|
if (source === 'telegram' && pendingConfirm && !trimmed.startsWith('/')) {
|
|
623
613
|
const confirm = pendingConfirm
|
|
624
614
|
pendingConfirm = null
|
|
@@ -812,13 +802,7 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
812
802
|
break
|
|
813
803
|
case 'stop':
|
|
814
804
|
// 停止当前正在运行的 AI 工作(如卡死 / 长时间无响应时)。
|
|
815
|
-
//
|
|
816
|
-
if (pendingAskUser) {
|
|
817
|
-
const cancel = cancelAskUser
|
|
818
|
-
pendingAskUser = null
|
|
819
|
-
cancelAskUser = null
|
|
820
|
-
if (cancel) cancel()
|
|
821
|
-
}
|
|
805
|
+
// AskUserQuestion 已改为异步发问(不挂起),无需在此取消 pending 等待。
|
|
822
806
|
if (engine.state.isRunning) {
|
|
823
807
|
engine.abort()
|
|
824
808
|
console.log('⏹️ 已发送停止信号,正在中止当前工作...')
|
|
@@ -1157,25 +1141,19 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1157
1141
|
}
|
|
1158
1142
|
engine.config.readline = rl
|
|
1159
1143
|
|
|
1160
|
-
// AskUserQuestion 工具回调 —
|
|
1161
|
-
// - Telegram
|
|
1162
|
-
//
|
|
1144
|
+
// AskUserQuestion 工具回调 — 按通道分流:
|
|
1145
|
+
// - Telegram 可用时:异步发问——发问题到 Telegram 后立即返回(不挂起 Promise、不锁死引擎),
|
|
1146
|
+
// 用户后续回复作为正常消息进入,由下一轮引擎处理时把回复当作回答。
|
|
1147
|
+
// 避免旧"挂起等待"结构导致会话卡死/发呆。
|
|
1148
|
+
// - CLI 本地模式:同步等待本地终端输入。
|
|
1163
1149
|
engine.config.onAskUser = (question) => {
|
|
1164
|
-
if (
|
|
1165
|
-
const promptText = `❓ ${question}\n\n
|
|
1150
|
+
if (tgListener?.bot && tgChatId) {
|
|
1151
|
+
const promptText = `❓ ${question}\n\n请直接回复你的回答。`
|
|
1166
1152
|
sendTelegram(promptText, null).catch(() => {})
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
// 用户回复任意文本即作为回答;用户 /stop 则通过 cancelAskUser 取消(resolve 取消标记,避免死锁)。
|
|
1170
|
-
pendingAskUser = { fn: (val) => resolve(val) }
|
|
1171
|
-
cancelAskUser = () => {
|
|
1172
|
-
if (pendingAskUser) pendingAskUser = null
|
|
1173
|
-
cancelAskUser = null
|
|
1174
|
-
resolve('(提问已取消)')
|
|
1175
|
-
}
|
|
1176
|
-
})
|
|
1153
|
+
// 立即返回,不挂起;用户回复会在下一轮以正常消息进入
|
|
1154
|
+
return `(已向用户提问,等待回复): ${question}`
|
|
1177
1155
|
}
|
|
1178
|
-
// CLI
|
|
1156
|
+
// CLI 本地模式:同步等待终端输入
|
|
1179
1157
|
return askQuestion(`❓ ${question}\n> `)
|
|
1180
1158
|
}
|
|
1181
1159
|
|