sintbot 1.0.16 → 1.3.0
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/CHANGELOG.en.json +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/core/pending.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
// 被打断的轮次:发版关停掐断的请求,重启后自动重做一遍。
|
|
2
|
-
//
|
|
3
|
-
// v1 只治**发版掐断**这一个来源(额度耗尽那条搁置到第二次事故)。两台生产机 25 天
|
|
4
|
-
// 549 轮里非 ok 共 35 条,但真正「欠着没做」的只有约 5 条 —— 其余是用户自己 /stop
|
|
5
|
-
// (「发错地方了吧」「只按照最终版」)或 agent 真做不到(模型不支持识图)。⇒ **事后扫
|
|
6
|
-
// ledger 判「该不该续」必然大面积误伤**,成因只能在失败当场打标。
|
|
7
|
-
//
|
|
8
|
-
// 为什么不从 ledger 回捞正文:ledger 的 text 是 `slice(0, 60)` 的摘要(549 行里 51 行
|
|
9
|
-
// 正好 60 字 = 已被截断),且不存 msgId ⇒ 拿它当续做输入会把请求截半。
|
|
10
|
-
//
|
|
11
|
-
// ⭐ 为什么**只在优雅关停路径记账**:`engine.shutdown()` 是 daemon 自己调的,那一刻确知
|
|
12
|
-
// 「这轮不是用户想停的」;崩溃/SIGKILL 根本走不到那行代码 ⇒ **能触发续做的只有 operator
|
|
13
|
-
// 亲手敲的 kickstart,而那是白天敲的、人就在旁边**。「防半夜自己开工」的闸门在这条路上
|
|
14
|
-
// 没有靶子,故 v1 不做闸门、不给旋钮。反悔阀 = 现成的 /stop(重投前群里先说一句)。
|
|
15
|
-
//
|
|
16
|
-
// 不记的两类:①`settled` 的轮(答案已发进群、只差子进程没退干净,控制台显示 finalizing)
|
|
17
|
-
// —— 掐它不丢答案,续做等于把同一个答案再算一遍钱;②合成消息(定时任务 / 画布按钮)——
|
|
18
|
-
// 它们各有自己的触发源(下次到点还会跑、按钮可以再点),且这天然免掉「续做的轮又被掐断
|
|
19
|
-
// ⇒ 前缀层层嵌套」。⚠ 已知缺口:一次性 `--at` 定时任务被掐断就真丢了,v1 接受。
|
|
20
|
-
|
|
21
|
-
const fs = require('node:fs')
|
|
22
|
-
const path = require('node:path')
|
|
23
|
-
|
|
24
|
-
const FILE = 'pending-turns.json'
|
|
25
|
-
const DEFAULT_TTL_MINUTES = 120 // 「帮我看下现在的状态」隔一晚再跑就是噪音,不是补做
|
|
26
|
-
const MAX_TEXT = 4000 // 群消息本就有上限;截断只为挡畸形/被塞大文件的记录
|
|
27
|
-
const MAX_RECORDS = 20 // 并发上限远小于此;超出必是异常,别在启动时刷屏
|
|
28
|
-
|
|
29
|
-
const pendingFile = (dir) => path.join(dir, FILE)
|
|
30
|
-
|
|
31
|
-
// 记录合法性:这个文件躺在 agent 够得着的目录里,读回来的东西一律不可信(通则见
|
|
32
|
-
// project memory 的「内部入口不存在」那条)。⚠ 残余风险如实记:有 shell 的 agent 能
|
|
33
|
-
// 自造一条记录让 bot 重启后往某个群发一句话 —— 但它本就能直接往自己群里发消息,
|
|
34
|
-
// 这不是新增暴露面;真正的围栏在消费侧(只对**已绑定且未暂停**的群续做)。
|
|
35
|
-
function normalizeRecord(r) {
|
|
36
|
-
if (!r || typeof r !== 'object') return null
|
|
37
|
-
const chatId = typeof r.chatId === 'string' ? r.chatId.trim() : ''
|
|
38
|
-
const text = typeof r.text === 'string' ? r.text.trim().slice(0, MAX_TEXT) : ''
|
|
39
|
-
if (!chatId || !text) return null
|
|
40
|
-
const ts = Number(r.ts)
|
|
41
|
-
return {
|
|
42
|
-
chatId,
|
|
43
|
-
text,
|
|
44
|
-
msgId: typeof r.msgId === 'string' ? r.msgId : null,
|
|
45
|
-
sender: typeof r.sender === 'string' ? r.sender.slice(0, 80) : '',
|
|
46
|
-
ts: Number.isFinite(ts) && ts > 0 ? ts : 0,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 关停路径落盘:**必须同步** —— 调用方紧接着 process.exit,异步写会连文件都留不下。
|
|
51
|
-
// 先写临时文件再 rename(与 store 同款原子写):半截 JSON 比没有更糟,它会让下次启动
|
|
52
|
-
// 拿着残缺正文去重做。空数组不落文件。
|
|
53
|
-
function writePending(file, records, log = () => {}) {
|
|
54
|
-
const list = (Array.isArray(records) ? records : []).map(normalizeRecord).filter(Boolean).slice(0, MAX_RECORDS)
|
|
55
|
-
if (!list.length) return 0
|
|
56
|
-
try {
|
|
57
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
58
|
-
const tmp = file + '.tmp'
|
|
59
|
-
fs.writeFileSync(tmp, JSON.stringify(list, null, 2), { mode: 0o600 }) // 存的是请求原文
|
|
60
|
-
fs.renameSync(tmp, file)
|
|
61
|
-
return list.length
|
|
62
|
-
} catch (e) {
|
|
63
|
-
log('被打断的请求落盘失败: ' + (e && e.message))
|
|
64
|
-
return 0
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 读取即消费(读完就删):启动路径只此一次调用,删在前、用在后 —— 续做本身再崩也不会
|
|
69
|
-
// 让同一笔在每次重启时反复重投(「每笔只续一次」这条不变量落在这儿)。
|
|
70
|
-
function takePending(file, log = () => {}) {
|
|
71
|
-
let raw
|
|
72
|
-
try {
|
|
73
|
-
raw = fs.readFileSync(file, 'utf8')
|
|
74
|
-
} catch { return [] } // 没有文件 = 上次是干净退出,正常路径
|
|
75
|
-
try { fs.unlinkSync(file) } catch (e) { log('被打断的请求记录删除失败: ' + (e && e.message)) }
|
|
76
|
-
try {
|
|
77
|
-
const parsed = JSON.parse(raw)
|
|
78
|
-
return (Array.isArray(parsed) ? parsed : []).map(normalizeRecord).filter(Boolean).slice(0, MAX_RECORDS)
|
|
79
|
-
} catch (e) {
|
|
80
|
-
log('被打断的请求记录读取失败(格式错误,已丢弃): ' + (e && e.message))
|
|
81
|
-
return []
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// 按时效分两堆:due 重做,expired 只记一行日志。ts 缺失(0)当过期处理 —— 判不了新旧
|
|
86
|
-
// 就别重做,fail-safe 必须往「不动手」倒。
|
|
87
|
-
function resumable(records, { now = 0, ttlMinutes = DEFAULT_TTL_MINUTES } = {}) {
|
|
88
|
-
const ttlMs = Math.max(0, Number(ttlMinutes) || 0) * 60_000
|
|
89
|
-
const due = []
|
|
90
|
-
const expired = []
|
|
91
|
-
for (const r of Array.isArray(records) ? records : []) {
|
|
92
|
-
const rec = normalizeRecord(r)
|
|
93
|
-
if (!rec) continue
|
|
94
|
-
if (!rec.ts || now - rec.ts > ttlMs) expired.push(rec)
|
|
95
|
-
else due.push(rec)
|
|
96
|
-
}
|
|
97
|
-
return { due, expired }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 重投给 agent 的正文。前缀不是客套 —— **被掐断的轮多半没留下会话记录**(codex 只在
|
|
101
|
-
// 干净退出才落盘 rollout,claude 侧 setSession 也只在 runner resolve 时才调)⇒ agent
|
|
102
|
-
// 对自己做了一半的活儿是失忆的。不明说,它就会把「改完提交」原样再做一遍。
|
|
103
|
-
function resumePrompt(rec) {
|
|
104
|
-
return '(这条是上次运行时被打断的请求 —— 当时正在处理它,重启把进程掐断了,现在重做一遍。\n' +
|
|
105
|
-
'⚠ 你可能已经做过一部分,而被掐断的那轮通常没有留下会话记录,你对它是失忆的 ⇒ 动手前先确认\n' +
|
|
106
|
-
'当前状态(文件改了没、提交了没、消息发了没),别重复执行有副作用的操作。只读的查询直接重做。)\n\n' +
|
|
107
|
-
String((rec && rec.text) || '')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 重投前的群内告知。**不是问询、不等确认** —— 等确认要一整套回复匹配状态机,而反悔用
|
|
111
|
-
// 现成的 /stop 就够。发它的唯一理由:bot 无端开工比不开工更吓人。
|
|
112
|
-
// ⚠ 纯文本消息不渲染 markdown ⇒ 层次只能用【】与「· 」(群消息体裁,v0.11.11)。
|
|
113
|
-
function resumeNotice(rec, { maxQuote = 60 } = {}) {
|
|
114
|
-
const t = String((rec && rec.text) || '').replace(/\s+/g, ' ').trim()
|
|
115
|
-
const quote = t.length > maxQuote ? t.slice(0, maxQuote) + '…' : t
|
|
116
|
-
const L = ['【继续上次被打断的任务】', '上次运行时我正在处理这条,重启把它打断了 —— 现在重做一遍。']
|
|
117
|
-
if (rec && rec.sender) L.push(`· 提问人:${rec.sender}`)
|
|
118
|
-
L.push(`· 原文:${quote}`)
|
|
119
|
-
L.push('· 不需要就发 /stop 停掉')
|
|
120
|
-
return L.join('\n')
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
module.exports = { pendingFile, writePending, takePending, resumable, resumePrompt, resumeNotice, DEFAULT_TTL_MINUTES }
|
package/src/core/proxy.js
DELETED
|
@@ -1,424 +0,0 @@
|
|
|
1
|
-
// 出网代理(CONNECT 隧道)—— 管的是 **daemon 自己出网**这一层。
|
|
2
|
-
//
|
|
3
|
-
// 由来(2026-09-01 实测撞出来):
|
|
4
|
-
// **Node 的 `fetch`(undici)与 `ws` 都不读 `http_proxy` / `HTTPS_PROXY`**。同一台 Mac、
|
|
5
|
-
// 同一个 Clash 系统代理(127.0.0.1:7890):`curl` 通、Chrome 通,而 Node 直连被墙。
|
|
6
|
-
// 挂上隧道后 REST 1351ms / WS 1479ms,直连两条都是 12s 超时。
|
|
7
|
-
//
|
|
8
|
-
// ⚠ **这个洞最贵的地方是错误形态不说人话** ⇒ 极易误读成「平台挂了 / 账号有问题 / token 不对」:
|
|
9
|
-
// · `fetch` → `TypeError: fetch failed`,cause 是 `UND_ERR_CONNECT_TIMEOUT`
|
|
10
|
-
// · `ws` → **「连上了但一直没 Hello」**,连 error 都不报,就是干等
|
|
11
|
-
// 两者都不会说「你没走代理」。所以本模块凡是拒绝出网,都必须**自己把原因说出来**。
|
|
12
|
-
// ⚠ 更贵的一层:Clash 一类的 **TUN / 增强模式**接管全部流量时,Node 不配置也能出去 ——
|
|
13
|
-
// 于是开发机上「明明是好的」,别人那台不是。**「我这儿是好的」在这条上没有信息量。**
|
|
14
|
-
//
|
|
15
|
-
// ⚠ `src/config.js` 的 `applyProxyEnv` 只把代理**塞进 env 给 spawn 出去的 agent CLI 继承**
|
|
16
|
-
// (注释原文 "so every spawned `claude` child inherits it")—— **daemon 自己出网不经过它**。
|
|
17
|
-
// 飞书 SDK 自带 http 栈所以从没暴露过;是 Slack 适配器用裸 `fetch` + 裸 `new WebSocket()`
|
|
18
|
-
// 才把这个洞顶出来。本模块就是补这一层,读的仍是 `applyProxyEnv` 落下的那几个 env 变量
|
|
19
|
-
// ⇒ **配置只有 `cfg.proxy` 一个来源**,daemon 与子进程走同一份,不会各读各的。
|
|
20
|
-
//
|
|
21
|
-
// ⭐ 零依赖能解:HTTP `CONNECT` + `tls.connect` 包成 `https.Agent` 子类,REST 与 WS
|
|
22
|
-
// **共用同一个 agent**。⇒ `src/design.js`「从参考提炼」段头那条「要支持得引依赖 —— 这是零依赖守则
|
|
23
|
-
// 不换的东西」**已被推翻**(要引依赖的是 undici 的 `ProxyAgent`/`setGlobalDispatcher`,
|
|
24
|
-
// 那是另一条路)。⚠ REST 必须从 `fetch` 换成 `node:https` —— **`fetch` 没有挂 agent 的口子**。
|
|
25
|
-
//
|
|
26
|
-
// ⚠ **只支持 `http://` 的代理**(明文 HTTP CONNECT)。`socks5://` / `https://` 一律不认:
|
|
27
|
-
// 把 SOCKS 端口当 HTTP 代理发 CONNECT,在 Clash 的 mixed-port 上会**恰好碰对**(同一端口
|
|
28
|
-
// 两种协议都收),纯 SOCKS 端口上则是 `socket hang up` 或干挂 —— 又是那种不说人话的错。
|
|
29
|
-
// 有的机器 shell 里就配着 `all_proxy=socks5://…`,所以这条不是假想。
|
|
30
|
-
|
|
31
|
-
const http = require('node:http')
|
|
32
|
-
const https = require('node:https')
|
|
33
|
-
const net = require('node:net')
|
|
34
|
-
const tls = require('node:tls')
|
|
35
|
-
|
|
36
|
-
// 与 applyProxyEnv 落下的键一一对应(大小写两套都读:各家工具约定不统一)。
|
|
37
|
-
// ⚠ 顺序即优先级 —— 改顺序要同步改所有用到它的地方,不然会出现「这条路通、那条路不通」这种最难查的差异。
|
|
38
|
-
const PROXY_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
|
|
39
|
-
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy']
|
|
40
|
-
|
|
41
|
-
function parseProxyUrl(v) {
|
|
42
|
-
try { return new URL(String(v).trim()) } catch { return null }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 从 env 读**可用的**代理地址(只认 `http:`);没配、或配的都不是 http 代理,返回 ''。
|
|
47
|
-
* ⚠ 不可用的那种不是静默跳过 —— `proxyWarning()` 会把它说出来,给启动日志/doctor 用。
|
|
48
|
-
*/
|
|
49
|
-
function proxyFromEnv(env = process.env) {
|
|
50
|
-
for (const k of PROXY_KEYS) {
|
|
51
|
-
const v = env && env[k]
|
|
52
|
-
if (!v || !String(v).trim()) continue
|
|
53
|
-
const u = parseProxyUrl(v)
|
|
54
|
-
if (u && u.protocol === 'http:') return String(v).trim()
|
|
55
|
-
}
|
|
56
|
-
return ''
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* env 里有代理变量、但本模块用不了时,给一句能照做的话;正常情况返回 ''。
|
|
61
|
-
* 典型:`all_proxy=socks5://127.0.0.1:7890` —— shell 里常有,而我们只会发 HTTP CONNECT。
|
|
62
|
-
*/
|
|
63
|
-
function proxyWarning(env = process.env) {
|
|
64
|
-
if (proxyFromEnv(env)) return ''
|
|
65
|
-
for (const k of PROXY_KEYS) {
|
|
66
|
-
const v = env && env[k]
|
|
67
|
-
if (!v || !String(v).trim()) continue
|
|
68
|
-
const u = parseProxyUrl(v)
|
|
69
|
-
const scheme = u ? u.protocol.replace(':', '') : '(无法解析)'
|
|
70
|
-
return `环境变量 ${k}=${describeUrl(v)} 不是 http 代理(${scheme}),出网会直连。`
|
|
71
|
-
+ `要走代理请在配置 proxy.http 里填 http:// 地址(Clash 的 mixed-port 同一端口也收 http)`
|
|
72
|
-
}
|
|
73
|
-
return ''
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function noProxyFromEnv(env = process.env) {
|
|
77
|
-
for (const k of NO_PROXY_KEYS) {
|
|
78
|
-
const v = env && env[k]
|
|
79
|
-
if (v && String(v).trim()) return String(v).trim()
|
|
80
|
-
}
|
|
81
|
-
return ''
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* NO_PROXY 匹配。⚠ 必须实现:`applyProxyEnv` 会把 `deriveNoProxy` 生成的国内端点写进
|
|
86
|
-
* NO_PROXY 给子进程用,daemon 这层不认的话,**同一份配置在父子进程里含义不同** ——
|
|
87
|
-
* 那种不一致平时无声,出事时最难查。
|
|
88
|
-
*
|
|
89
|
-
* 口径(与 curl 对齐的那部分):`*` 全绕过 · `.example.com` / `*.example.com` 后缀 ·
|
|
90
|
-
* `example.com` 精确且蕴含子域 · 条目可带端口(忽略:只按主机名分流)· 逗号或空白分隔。
|
|
91
|
-
* ⚠ **IP 地址只做精确匹配**:`10.0.0.1` 不该被条目 `1` 或 `0.0.1` 按"后缀"命中 —— 域名的
|
|
92
|
-
* 后缀是层级,IP 的后缀什么都不是。IPv6 条目按字面比(带不带方括号都认),不做 CIDR。
|
|
93
|
-
*/
|
|
94
|
-
function shouldBypass(hostname, noProxy) {
|
|
95
|
-
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
96
|
-
if (!host) return false
|
|
97
|
-
const hostIsIp = net.isIP(host) !== 0
|
|
98
|
-
const list = String(noProxy || '').split(/[,\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
99
|
-
for (const raw of list) {
|
|
100
|
-
if (raw === '*') return true
|
|
101
|
-
const entry = stripEntryPort(raw).replace(/^\*/, '').replace(/\.$/, '')
|
|
102
|
-
if (!entry) continue
|
|
103
|
-
if (hostIsIp || net.isIP(entry.replace(/^\./, '')) !== 0) {
|
|
104
|
-
// 任一侧是 IP ⇒ 只认精确相等(去掉前导点后比)
|
|
105
|
-
if (host === entry.replace(/^\./, '')) return true
|
|
106
|
-
continue
|
|
107
|
-
}
|
|
108
|
-
if (entry.startsWith('.')) { if (host === entry.slice(1) || host.endsWith(entry)) return true }
|
|
109
|
-
else if (host === entry || host.endsWith('.' + entry)) return true
|
|
110
|
-
}
|
|
111
|
-
return false
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 条目里的端口:`host:8080` 剥掉,`[::1]:8080` 剥掉,而裸 IPv6 `::1` **不能**被当成"host + 端口 1"。
|
|
115
|
-
function stripEntryPort(raw) {
|
|
116
|
-
const m = /^\[([^\]]+)\](?::\d+)?$/.exec(raw)
|
|
117
|
-
if (m) return m[1]
|
|
118
|
-
if ((raw.match(/:/g) || []).length > 1) return raw // 多个冒号 = 裸 IPv6 字面量
|
|
119
|
-
return raw.replace(/:\d+$/, '')
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// 透传给 tls.connect 的信任类选项(agent 层给的 ca / rejectUnauthorized 等不该被隧道吞掉;
|
|
123
|
-
// 其余如 host/port/path/agent/headers 是 http 层的,tls.connect 不认或会误解)。
|
|
124
|
-
const TLS_PASSTHROUGH = ['ca', 'cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'checkServerIdentity',
|
|
125
|
-
'ALPNProtocols', 'minVersion', 'maxVersion', 'ciphers', 'secureContext', 'secureProtocol', 'crl', 'ecdhCurve']
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* CONNECT 隧道 agent。REST(node:https)与 WS(ws 的 `{ agent }`)共用同一个实例 ——
|
|
129
|
-
* 共用不只是省事:两条链路走同一个代理、同一套 keepAlive,出问题时排查面才是一个。
|
|
130
|
-
*
|
|
131
|
-
* `createConnection` 用回调式签名 —— Node 18.17 / 20 / 22 的 `_http_agent.js` 都是
|
|
132
|
-
* `const s = this.createConnection(options, oncreate); if (s) oncreate(null, s)`,两种形态都认。
|
|
133
|
-
*/
|
|
134
|
-
class TunnelAgent extends https.Agent {
|
|
135
|
-
// `httpImpl` 只为单测留的注入口 —— 隧道的成功路径要真 TLS,测不了;而**出错路径才是要害**
|
|
136
|
-
// (407 / 代理没起来 / 代理收了 TCP 却不回 CONNECT),那几条必须测得到,否则又回到
|
|
137
|
-
// "报一个指不到这里的错"。
|
|
138
|
-
constructor(proxyUrl, { httpImpl = http, connectTimeoutMs = 15_000, ...opts } = {}) {
|
|
139
|
-
super({ keepAlive: true, ...opts })
|
|
140
|
-
const u = parseProxyUrl(proxyUrl)
|
|
141
|
-
if (!u) throw new TypeError(`代理地址无法解析: ${describeUrl(proxyUrl)}`)
|
|
142
|
-
if (u.protocol !== 'http:') {
|
|
143
|
-
throw new TypeError(`只支持 http:// 的代理,拿到的是 ${u.protocol}//…(${describeUrl(proxyUrl)})—— socks5/https 代理这里发不了 CONNECT`)
|
|
144
|
-
}
|
|
145
|
-
this.proxy = u
|
|
146
|
-
this.proxyUrl = String(proxyUrl).trim()
|
|
147
|
-
this._http = httpImpl
|
|
148
|
-
this.connectTimeoutMs = connectTimeoutMs
|
|
149
|
-
// 认证头算一次:CONNECT 与明文绝对 URI 两条路都要带,各算一份必漂移
|
|
150
|
-
this.proxyAuth = u.username
|
|
151
|
-
? 'Basic ' + Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password || '')}`).toString('base64')
|
|
152
|
-
: ''
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
createConnection(options, cb) {
|
|
156
|
-
const host = String(options.host || '')
|
|
157
|
-
const target = `${host.includes(':') ? `[${host}]` : host}:${options.port || 443}` // IPv6 目标要带括号
|
|
158
|
-
const headers = { Host: target }
|
|
159
|
-
if (this.proxyAuth) headers['Proxy-Authorization'] = this.proxyAuth
|
|
160
|
-
const req = this._http.request({
|
|
161
|
-
host: this.proxy.hostname,
|
|
162
|
-
port: this.proxy.port || 80,
|
|
163
|
-
method: 'CONNECT',
|
|
164
|
-
path: target,
|
|
165
|
-
headers,
|
|
166
|
-
agent: false,
|
|
167
|
-
})
|
|
168
|
-
req.once('connect', (res, socket) => {
|
|
169
|
-
if (res.statusCode !== 200) {
|
|
170
|
-
socket.destroy()
|
|
171
|
-
// ⚠ 407 = 代理要认证但 URL 里没带凭据。非 200 一律当隧道没建起来、**不硬往下走**:
|
|
172
|
-
// 往下走的话 TLS 握手会在代理返回的错误页上失败,报出来的是一个指不到这里的密码学错误。
|
|
173
|
-
return cb(new Error(`代理 CONNECT ${target} → ${res.statusCode} ${res.statusMessage || ''}`.trim()))
|
|
174
|
-
}
|
|
175
|
-
const tlsOpts = { socket, servername: options.servername || host }
|
|
176
|
-
for (const k of TLS_PASSTHROUGH) if (options[k] !== undefined) tlsOpts[k] = options[k]
|
|
177
|
-
const sock = tls.connect(tlsOpts, () => cb(null, sock))
|
|
178
|
-
sock.once('error', cb)
|
|
179
|
-
})
|
|
180
|
-
req.once('error', (e) => cb(new Error(`连不上代理 ${this.proxy.origin}: ${e.message}`)))
|
|
181
|
-
// ⚠ CONNECT 自己要有超时:代理端口通、但那头根本不是 HTTP 代理(配错端口指到别的服务)时,
|
|
182
|
-
// TCP 建得起来、CONNECT 永远没有回应。外层请求的 AbortSignal 触发 destroy 时,这个
|
|
183
|
-
// 挂着的 CONNECT 请求并不随之收掉 —— 每次超时就泄一条 TCP(reviewer 用黑洞代理复现过:
|
|
184
|
-
// 3 次超时 = 代理侧 3 条连接全留着)。15s 与探针的 20s 同量级。
|
|
185
|
-
if (this.connectTimeoutMs > 0) {
|
|
186
|
-
req.setTimeout(this.connectTimeoutMs, () => req.destroy(new Error(`CONNECT ${this.connectTimeoutMs / 1000}s 没有回应 —— 那个端口多半不是 HTTP 代理`)))
|
|
187
|
-
}
|
|
188
|
-
req.end()
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── fetch 形状的响应(只实现调用方真用到的那几个成员)──────────────────────────────
|
|
193
|
-
// ⚠ 刻意不做成"完整的 Response 垫片":假装完整反而会让某天用到未实现成员的人拿到
|
|
194
|
-
// `undefined` 而不是报错。用到什么补什么,缺的当场就炸得出来。
|
|
195
|
-
function makeResponse(status, rawHeaders, buf, url, flags = null) {
|
|
196
|
-
const lower = new Map()
|
|
197
|
-
for (const [k, v] of Object.entries(rawHeaders || {})) {
|
|
198
|
-
lower.set(String(k).toLowerCase(), Array.isArray(v) ? v.join(', ') : String(v))
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
url,
|
|
202
|
-
status,
|
|
203
|
-
ok: status >= 200 && status < 300,
|
|
204
|
-
headers: { get: (n) => (lower.has(String(n).toLowerCase()) ? lower.get(String(n).toLowerCase()) : null) },
|
|
205
|
-
async text() { return buf.toString('utf8') },
|
|
206
|
-
async json() { return JSON.parse(buf.toString('utf8')) },
|
|
207
|
-
async arrayBuffer() { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) },
|
|
208
|
-
...(flags || {}),
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function bodyToBuffer(body) {
|
|
213
|
-
if (body === undefined || body === null) return null
|
|
214
|
-
if (Buffer.isBuffer(body)) return body
|
|
215
|
-
// URLSearchParams(出站一律 form 编码,见 docs/SLACK.md「静默失败」)与普通字符串都走这里
|
|
216
|
-
return Buffer.from(typeof body === 'string' ? body : String(body), 'utf8')
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// `init.headers` 三种形态都认:普通对象 / `Headers` 实例 / `[k,v]` 数组。
|
|
220
|
-
// ⚠ `{...new Headers()}` 会得到 `{}` —— 头**静默全丢**,请求照发、只是没了 Authorization。
|
|
221
|
-
function normalizeHeaders(h) {
|
|
222
|
-
if (!h) return {}
|
|
223
|
-
if (typeof h.forEach === 'function' && !Array.isArray(h)) { const o = {}; h.forEach((v, k) => { o[k] = v }); return o }
|
|
224
|
-
if (Array.isArray(h)) return Object.fromEntries(h)
|
|
225
|
-
return { ...h }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
|
|
229
|
-
const REDIRECT_MAX = 20 // 与 fetch 对齐
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* 造一个 fetch 形状、但底层走 `node:https` + 隧道 agent 的函数。
|
|
233
|
-
*
|
|
234
|
-
* ⭐ **做成 fetch 形状是关键的架构选择**:适配器里每一处出网都已经有 `fetchImpl` 注入口
|
|
235
|
-
* (30 个零网络单测靠的就是它)⇒ 只换默认实现,调用点与测试一行都不用动。
|
|
236
|
-
*
|
|
237
|
-
* @param {string} proxyUrl 代理地址;传空则所有请求直连(等价于不挂隧道)
|
|
238
|
-
*/
|
|
239
|
-
function createProxyFetch(proxyUrl, { noProxy = '', agent: injectedAgent = null, httpsImpl = https, httpImpl = http } = {}) {
|
|
240
|
-
const agent = proxyUrl ? (injectedAgent || new TunnelAgent(proxyUrl)) : null
|
|
241
|
-
|
|
242
|
-
return function proxiedFetch(url, init = {}) {
|
|
243
|
-
return request(String(url), init, 0)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function request(url, init, depth) {
|
|
247
|
-
return new Promise((resolve, reject) => {
|
|
248
|
-
// ⚠ 重定向环在 Slack 的下载链路上真实存在过(凭据无效时它用 200 + 登录页表达,但网关
|
|
249
|
-
// 抖动时也见过 302 打转)—— 不设限就是静默挂死。
|
|
250
|
-
if (depth > REDIRECT_MAX) return reject(new Error(`重定向超过 ${REDIRECT_MAX} 跳: ` + url))
|
|
251
|
-
const signal = init.signal
|
|
252
|
-
// ⚠ 已经取消的 signal:**在创建请求之前**就拒 —— 不是"创建了再 destroy"。
|
|
253
|
-
// 后者会先把 CONNECT + TLS 整条隧道建起来(reviewer 现场:能走到 onSocketNT 说明隧道
|
|
254
|
-
// 已完整建好),然后 Node 在 socket 分配时无条件 emit 'error'('socket hang up');
|
|
255
|
-
// 而那时 `req.on('error')` 还没挂 ⇒ **未捕获异常,daemon 整个崩**。
|
|
256
|
-
if (signal && signal.aborted) return reject(abortError(signal))
|
|
257
|
-
let u
|
|
258
|
-
try { u = new URL(url) } catch { return reject(new TypeError('无效的 URL: ' + url)) }
|
|
259
|
-
|
|
260
|
-
const isHttps = u.protocol === 'https:'
|
|
261
|
-
if (!isHttps && u.protocol !== 'http:') return reject(new TypeError('不支持的协议: ' + u.protocol))
|
|
262
|
-
|
|
263
|
-
const bypass = !agent || shouldBypass(u.hostname, noProxy)
|
|
264
|
-
const headers = normalizeHeaders(init.headers)
|
|
265
|
-
const method = String(init.method || 'GET').toUpperCase()
|
|
266
|
-
const buf = bodyToBuffer(init.body)
|
|
267
|
-
if (buf && headers['Content-Length'] === undefined && headers['content-length'] === undefined) {
|
|
268
|
-
headers['Content-Length'] = String(buf.length)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// http:(明文)走代理是**绝对形式 URI**,不是 CONNECT —— 两者搞混的表现是代理回 400。
|
|
272
|
-
// 这条链路上目前没有明文目标(Slack/Discord 都是 https),留着是为了不留一个静默直连的口子。
|
|
273
|
-
const viaAbsolute = !isHttps && !bypass
|
|
274
|
-
const lib = isHttps ? httpsImpl : httpImpl
|
|
275
|
-
const opts = viaAbsolute
|
|
276
|
-
? { host: agent.proxy.hostname, port: agent.proxy.port || 80, method, path: u.toString(), headers: { ...headers, Host: u.host, ...(agent.proxyAuth ? { 'Proxy-Authorization': agent.proxyAuth } : {}) }, agent: false }
|
|
277
|
-
: { host: u.hostname, port: u.port || (isHttps ? 443 : 80), method, path: u.pathname + u.search, headers, agent: bypass ? undefined : agent }
|
|
278
|
-
|
|
279
|
-
const viaProxy = !bypass && !!agent
|
|
280
|
-
const wrap = (e) => (viaProxy ? new Error(`${e.message}(经代理 ${agent.proxy.origin})`) : e)
|
|
281
|
-
|
|
282
|
-
const req = lib.request(opts, (res) => {
|
|
283
|
-
const status = res.statusCode
|
|
284
|
-
const loc = res.headers && res.headers.location
|
|
285
|
-
const mode = init.redirect || 'follow' // fetch 的默认就是 follow
|
|
286
|
-
if (loc && REDIRECT_CODES.has(status) && mode !== 'manual') {
|
|
287
|
-
res.resume() // 必须排干,否则 keepAlive 的连接不会被释放
|
|
288
|
-
if (mode === 'error') return reject(new Error(`redirect:'error' 而对端回了 ${status} → ${loc}`))
|
|
289
|
-
let next
|
|
290
|
-
try { next = new URL(loc, u).toString() } catch { return reject(new Error('重定向地址无法解析: ' + loc)) }
|
|
291
|
-
const nextInit = { ...init, headers: { ...headers } }
|
|
292
|
-
// ⚠ **跨源必须摘掉 Authorization** —— 与 fetch 同口径。Slack 的文件下载正是
|
|
293
|
-
// `files.slack.com` → 签名 CDN 地址这种跳法,带着 `xoxb-` 跳过去就是把 bot token
|
|
294
|
-
// 送给第三方主机。签名已经在 URL 里,摘掉不影响下载。
|
|
295
|
-
if (new URL(next).origin !== u.origin) {
|
|
296
|
-
for (const k of Object.keys(nextInit.headers)) {
|
|
297
|
-
if (/^(authorization|cookie|proxy-authorization)$/i.test(k)) delete nextInit.headers[k]
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
// 303(除 GET/HEAD 外),以及 301/302 上的 POST:按 fetch 口径降级成 GET 且丢掉 body
|
|
301
|
-
const toGet = (status === 303 && method !== 'GET' && method !== 'HEAD') || ((status === 301 || status === 302) && method === 'POST')
|
|
302
|
-
if (toGet) {
|
|
303
|
-
nextInit.method = 'GET'
|
|
304
|
-
delete nextInit.body
|
|
305
|
-
for (const k of Object.keys(nextInit.headers)) {
|
|
306
|
-
// body 没了,描述 body 的头也得跟着走 —— 留着 Content-Length 对端会一直等一个不会来的 body
|
|
307
|
-
if (/^(content-length|content-type|content-encoding|content-language|content-location)$/i.test(k)) delete nextInit.headers[k]
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return resolve(request(next, nextInit, depth + 1))
|
|
311
|
-
}
|
|
312
|
-
// init.maxBytes(非标准,fetch 会忽略它):响应体边收边数,到线就**截断**并断开 —— 这条路把整个响应
|
|
313
|
-
// 攒进内存,不封顶的话一个几百 MB 的响应就能把 daemon 撑爆(风格提炼会替人去拉任意公网页面)。
|
|
314
|
-
// 截断而不是报错:要的往往只是开头(HTML 的 <head> 里就是全部样式表链接)。响应带 capped / truncated 两个标记。
|
|
315
|
-
const chunks = []
|
|
316
|
-
let got = 0
|
|
317
|
-
const cap = Number(init.maxBytes) > 0 ? Number(init.maxBytes) : 0
|
|
318
|
-
const flags = cap ? { capped: true, truncated: false } : null
|
|
319
|
-
res.on('data', (c) => {
|
|
320
|
-
if (flags && flags.truncated) return
|
|
321
|
-
if (cap && got + c.length > cap) {
|
|
322
|
-
chunks.push(c.subarray(0, cap - got)); got = cap; flags.truncated = true
|
|
323
|
-
try { res.destroy() } catch {}
|
|
324
|
-
return resolve(makeResponse(status, res.headers, Buffer.concat(chunks), u.toString(), flags))
|
|
325
|
-
}
|
|
326
|
-
got += c.length
|
|
327
|
-
chunks.push(c)
|
|
328
|
-
})
|
|
329
|
-
res.on('end', () => resolve(makeResponse(status, res.headers, Buffer.concat(chunks), u.toString(), flags)))
|
|
330
|
-
res.on('error', (e) => reject(wrap(e))) // 半截响应 / 中途断线走这条,也得点名代理
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
// 调用方给的是 `AbortSignal.timeout(...)`(适配器那层已有超时口径)⇒ 这里只负责响应它,
|
|
334
|
-
// 不自己再叠一个:两层超时会让"到底谁掐的"变成一个查不清的问题。
|
|
335
|
-
// ⚠ 监听器要在请求结束时**摘掉**:`AbortSignal.timeout` 的信号一旦挂上监听器就被 Node 钉住
|
|
336
|
-
// (gcPersistentSignals)直到计时到点 —— 不摘,每次调用后 req→res→body 都被钉整整 timeoutMs;
|
|
337
|
-
// 长寿的 signal(复用一个 AbortController)上则只增不减。
|
|
338
|
-
const onAbort = () => { req.destroy(); reject(abortError(signal)) }
|
|
339
|
-
if (signal) {
|
|
340
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
341
|
-
req.once('close', () => signal.removeEventListener('abort', onAbort))
|
|
342
|
-
}
|
|
343
|
-
// ⚠ 这条 message 是本模块存在的理由的一半:直连超时的原始报错完全指不到"没走代理"。
|
|
344
|
-
req.on('error', (e) => reject(wrap(e)))
|
|
345
|
-
if (buf) req.write(buf)
|
|
346
|
-
req.end()
|
|
347
|
-
})
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function abortError(signal) {
|
|
352
|
-
const e = new Error((signal && signal.reason && signal.reason.message) || 'aborted')
|
|
353
|
-
e.name = (signal && signal.reason && signal.reason.name) || 'AbortError'
|
|
354
|
-
return e
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ── 模块级单例(进程内共用一个 agent / 一个 fetch)────────────────────────────────
|
|
358
|
-
// ⚠ 惰性 + 可重置:`applyProxyEnv` 在 `loadConfig` 之后才写 env,而本模块可能在那之前
|
|
359
|
-
// 就被 require 进来 ⇒ **不能在 require 时就定形**。
|
|
360
|
-
// ⚠ 但**别指望热重载会换代理**:`applyProxyEnv` 只在 env 里没有时才写(不覆盖 shell 已有值),
|
|
361
|
-
// 改了配置里的 proxy,env 不变、这里的 key 也就不变。要换代理得重启 daemon。`resetProxy()`
|
|
362
|
-
// 是给测试和显式调用留的,不是热重载钩子。
|
|
363
|
-
let cached = null
|
|
364
|
-
let cachedKey = null
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* 取一个代理感知的 fetch;**没配代理时返回 null**(调用方回落到全局 fetch)。
|
|
368
|
-
* ⭐ 返回 null 而不是"透明直连的包装" —— 没配代理的绝大多数机器上,行为与改动前逐字节相同,
|
|
369
|
-
* 这条改动的风险面因此只覆盖"真的配了代理"的那部分。
|
|
370
|
-
*/
|
|
371
|
-
function getProxyFetch(env = process.env) {
|
|
372
|
-
const p = proxyFromEnv(env)
|
|
373
|
-
if (!p) return null
|
|
374
|
-
ensure(env, p)
|
|
375
|
-
return cached.fetch
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** 取共用的隧道 agent(给 `ws` 的 `{ agent }` 用);没配代理返回 null。 */
|
|
379
|
-
function getProxyAgent(env = process.env) {
|
|
380
|
-
const p = proxyFromEnv(env)
|
|
381
|
-
if (!p) return null
|
|
382
|
-
ensure(env, p)
|
|
383
|
-
return cached.agent
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function ensure(env, p) {
|
|
387
|
-
const np = noProxyFromEnv(env)
|
|
388
|
-
const key = p + '|' + np
|
|
389
|
-
if (cached && cachedKey === key) return
|
|
390
|
-
// 换了就先关旧 agent 的 keepAlive 连接再换新的,否则每换一次泄一批 socket。
|
|
391
|
-
// (在途 REST 会被打成 aborted,createSlackApi 的重试吸收得住;已升级的 WS 早被 Node 从
|
|
392
|
-
// agent 上摘走 —— 不受影响,但也就意味着它会走旧隧道直到自然重连。)
|
|
393
|
-
resetProxy()
|
|
394
|
-
const agent = new TunnelAgent(p)
|
|
395
|
-
cached = { agent, fetch: createProxyFetch(p, { noProxy: np, agent }) }
|
|
396
|
-
cachedKey = key
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** 丢弃单例(测试 / 显式重建)。 */
|
|
400
|
-
function resetProxy() {
|
|
401
|
-
if (cached && cached.agent && cached.agent.destroy) { try { cached.agent.destroy() } catch { /* 已经关了 */ } }
|
|
402
|
-
cached = null
|
|
403
|
-
cachedKey = null
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** 把代理 URL 变成可进日志的样子。⚠ 不打印凭据:URL 里可能带用户名密码。 */
|
|
407
|
-
function describeUrl(v) {
|
|
408
|
-
const u = parseProxyUrl(v)
|
|
409
|
-
if (!u) return '(无法解析)'
|
|
410
|
-
return `${u.protocol}//${u.hostname}:${u.port || (u.protocol === 'https:' ? 443 : 80)}${u.username ? '(带认证)' : ''}`
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** 给 doctor / 启动日志用的一句话描述;没配可用代理返回 ''。 */
|
|
414
|
-
function proxyDescribe(env = process.env) {
|
|
415
|
-
const p = proxyFromEnv(env)
|
|
416
|
-
return p ? describeUrl(p) : ''
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
module.exports = {
|
|
420
|
-
PROXY_ENV_KEYS: [...PROXY_KEYS, ...NO_PROXY_KEYS],
|
|
421
|
-
proxyFromEnv, noProxyFromEnv, proxyWarning, shouldBypass,
|
|
422
|
-
TunnelAgent, createProxyFetch,
|
|
423
|
-
getProxyFetch, getProxyAgent, resetProxy, proxyDescribe,
|
|
424
|
-
}
|
package/src/core/quotaalert.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// 额度告警(v0.11.8)—— 订阅额度快见底时在群里说一声。
|
|
2
|
-
//
|
|
3
|
-
// 为什么要做:额度耗尽的现象是「轮次开始失败 / 报容量满」,群成员看到的只是 bot 忽然变笨
|
|
4
|
-
// 或报一句看不懂的英文,没人知道那是额度到顶;而 operator 是唯一能处理的人(等窗口重置 /
|
|
5
|
-
// 换模型)。与 v0.9.x「禁令必须配正路」同源:**故障要在发生前说,别让人从怪现象倒推**。
|
|
6
|
-
//
|
|
7
|
-
// 阈值(operator 2026-08-04 拍板,先写死不做配置面):
|
|
8
|
-
// Claude 5h 会话窗口 已用 > 90% —— 最常卡人的那档
|
|
9
|
-
// codex 7d 周窗口 已用 > 95% —— codex 的 5h 窗口**不告警**(用户明确只要周额度)
|
|
10
|
-
//
|
|
11
|
-
// 去重:窗口身份 = resetsAt,同一窗口只播一次(store.wasQuotaAlerted/markQuotaAlerted,
|
|
12
|
-
// 按 **provider** 存 —— 额度是账号级事实,多群共用一个订阅不该吵 N 遍)。
|
|
13
|
-
//
|
|
14
|
-
// 本模块只做判定与文案,不碰 IO —— 拉数据与发消息在 bin 接线(core 不认具体 runner 名)。
|
|
15
|
-
|
|
16
|
-
const CLAUDE_SESSION_THRESHOLD = 90
|
|
17
|
-
const CODEX_WEEKLY_THRESHOLD = 95
|
|
18
|
-
|
|
19
|
-
// 距重置的人话(告警文案用,比控制台那个更口语:「约 1.8 小时后」)
|
|
20
|
-
function humanReset(ms) {
|
|
21
|
-
if (!Number.isFinite(ms) || ms <= 0) return ''
|
|
22
|
-
const h = ms / 3600e3
|
|
23
|
-
if (h >= 24) { const d = h / 24; return `约 ${d < 10 ? Number(d.toFixed(1)) : Math.round(d)} 天后` }
|
|
24
|
-
if (h >= 1) return `约 ${h < 10 ? Number(h.toFixed(1)) : Math.round(h)} 小时后`
|
|
25
|
-
return `约 ${Math.max(1, Math.round(ms / 60e3))} 分钟后`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Claude 额度 → 待告警项 | null。limits 来自 claudeusage.parseLimits(percent 是**已用**)。
|
|
29
|
-
// 只认 kind==='session'(5h 窗口);其余档不告警(周窗口耗尽极罕见,且它到顶时 5h 早就先炸了)。
|
|
30
|
-
function claudeAlert(limits, now = Date.now()) {
|
|
31
|
-
if (!Array.isArray(limits)) return null
|
|
32
|
-
const l = limits.find((x) => x && x.kind === 'session')
|
|
33
|
-
if (!l || typeof l.percent !== 'number' || l.percent <= CLAUDE_SESSION_THRESHOLD) return null
|
|
34
|
-
return {
|
|
35
|
-
kind: 'claude-session',
|
|
36
|
-
percent: Math.min(100, Math.round(l.percent)),
|
|
37
|
-
resetsAt: typeof l.resetsAt === 'number' ? l.resetsAt : null,
|
|
38
|
-
resetIn: typeof l.resetsAt === 'number' && l.resetsAt > now ? l.resetsAt - now : null,
|
|
39
|
-
window: '5 小时',
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// codex 额度 → 待告警项 | null。usage 来自 codexstatus.mapRateLimits。
|
|
44
|
-
// ⚠ 周窗口按 **windowMins >= 1440** 判,**不能按 primary/secondary 位置** —— 官方把周窗口
|
|
45
|
-
// 挪到过 primary(v0.3.3 真机踩过:按位置写死会把周窗口误标成 5h)。
|
|
46
|
-
// resetsAt 兼容两种口径:>1e9 视作绝对 epoch 秒,否则视作「距重置的秒数」(与控制台同源)。
|
|
47
|
-
function codexAlert(usage, now = Date.now()) {
|
|
48
|
-
if (!usage) return null
|
|
49
|
-
const wins = [usage.primary, usage.secondary].filter((w) => w && typeof w.usedPercent === 'number')
|
|
50
|
-
const w = wins.find((x) => Number(x.windowMins) >= 1440)
|
|
51
|
-
if (!w || w.usedPercent <= CODEX_WEEKLY_THRESHOLD) return null
|
|
52
|
-
let resetIn = null
|
|
53
|
-
let resetsAt = null
|
|
54
|
-
if (w.resetsAt != null && Number.isFinite(Number(w.resetsAt))) {
|
|
55
|
-
const raw = Number(w.resetsAt)
|
|
56
|
-
const ms = raw > 1e9 ? raw * 1000 - now : raw * 1000
|
|
57
|
-
if (ms > 0 && ms < 30 * 864e5) { resetIn = ms; resetsAt = now + ms }
|
|
58
|
-
}
|
|
59
|
-
return {
|
|
60
|
-
kind: 'codex-weekly',
|
|
61
|
-
percent: Math.min(100, Math.round(w.usedPercent)),
|
|
62
|
-
// codex 不给绝对重置时刻(只有相对秒数)⇒ 用算出的绝对时刻当窗口身份。**取整到分钟**:
|
|
63
|
-
// 每轮算出的毫秒值都略有不同,不取整则每次都是「新窗口」,去重直接失效(这是本模块最容易
|
|
64
|
-
// 写错的一处 —— Claude 那侧给的是绝对时刻,天然稳定,codex 这侧必须自己稳住)。
|
|
65
|
-
resetsAt: resetsAt == null ? null : Math.round(resetsAt / 60e3) * 60e3,
|
|
66
|
-
resetIn,
|
|
67
|
-
window: '一周',
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// 告警文案(群里发的那一条)。零工具名:只说「本群使用的模型」,不出现 provider 名/命令名
|
|
72
|
-
// —— 群成员看到的产品只有一个身份(`brand`)。
|
|
73
|
-
function alertText(a) {
|
|
74
|
-
if (!a) return null
|
|
75
|
-
const when = a.resetIn ? humanReset(a.resetIn) : ''
|
|
76
|
-
return `⚠ 本群模型的 ${a.window}额度已用掉 ${a.percent}%${when ? `,${when}重置` : ''} —— 用完前可能变慢或失败,需要的话可以先缓一缓。`
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
module.exports = { claudeAlert, codexAlert, alertText, humanReset, CLAUDE_SESSION_THRESHOLD, CODEX_WEEKLY_THRESHOLD }
|