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/cdp.js
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
// 常驻浏览器的 CDP 客户端(Chrome DevTools Protocol)—— 控制台「接管浏览器」页的底座。
|
|
3
|
-
//
|
|
4
|
-
// 为什么自己写而不用 chrome-devtools-mcp:MCP 是给 agent 用的工具面(元素 uid、快照),接管页要的是
|
|
5
|
-
// **原始画面 + 原始输入** —— 截屏推流(Page.startScreencast)与按坐标的鼠标 / 键盘事件(Input.*),
|
|
6
|
-
// 且要在 daemon 进程里长连接、按需 attach 到某个标签页。MCP 没有这两样,也不该有(它面向 agent)。
|
|
7
|
-
//
|
|
8
|
-
// 传输:HTTP 端点(/json、/json/new …)走 node:http;协议通道走 WebSocket(`ws` 包 —— 飞书 SDK 已带同一份,
|
|
9
|
-
// 声明进依赖是为了不靠提升的巧合;Node 18/20 没有全局 WebSocket)。`WebSocketImpl` 可注入 ⇒ 单测用假的。
|
|
10
|
-
//
|
|
11
|
-
// ⚠ 只连 cfg.browser.cdpUrl(默认本机 9222)。9222 只绑 localhost,但本机任意进程都能经它操控已登录的浏览器
|
|
12
|
-
// (docs/BROWSER.md 安全口径)—— 这里不再加门,门在控制台那层(登录 / 一次性链接)。
|
|
13
|
-
|
|
14
|
-
const http = require('node:http')
|
|
15
|
-
|
|
16
|
-
const DEFAULT_CDP_URL = 'http://127.0.0.1:9222'
|
|
17
|
-
|
|
18
|
-
// —— HTTP 端点 ——
|
|
19
|
-
function httpJSON(url, { method = 'GET', timeoutMs = 3000 } = {}) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const u = new URL(url)
|
|
22
|
-
const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname + u.search, method, timeout: timeoutMs }, (res) => {
|
|
23
|
-
let b = ''
|
|
24
|
-
res.setEncoding('utf8')
|
|
25
|
-
res.on('data', (c) => { b += c })
|
|
26
|
-
res.on('end', () => {
|
|
27
|
-
if (res.statusCode >= 400) return reject(new Error(`浏览器端点 ${u.pathname} 返回 HTTP ${res.statusCode}`)) // 文案可能原样到接管页 / 群里,不带协议名
|
|
28
|
-
if (!b.trim()) return resolve(null)
|
|
29
|
-
try { resolve(JSON.parse(b)) } catch { resolve(b) } // /json/close 之类返回纯文本
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
req.on('timeout', () => req.destroy(new Error('浏览器端点超时')))
|
|
33
|
-
req.on('error', reject)
|
|
34
|
-
req.end()
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 浏览器还活着吗:/json/version(Browser 字段形如 "Chrome/152.0.7977.83")
|
|
39
|
-
const versionInfo = (cdpUrl = DEFAULT_CDP_URL, opts = {}) => httpJSON(`${cdpUrl}/json/version`, opts)
|
|
40
|
-
|
|
41
|
-
// 页面型 target 清单(过滤掉 chrome:// / devtools:// / 扩展页这类不是「网页」的东西)
|
|
42
|
-
async function listTargets(cdpUrl = DEFAULT_CDP_URL) {
|
|
43
|
-
const all = (await httpJSON(`${cdpUrl}/json`)) || []
|
|
44
|
-
return all
|
|
45
|
-
.filter((t) => t && t.type === 'page' && /^(https?:|about:|file:)/.test(String(t.url || '')))
|
|
46
|
-
.map((t) => ({ id: t.id, title: t.title || '', url: t.url || '', wsUrl: t.webSocketDebuggerUrl || '' }))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const newTarget = (cdpUrl, url) => httpJSON(`${cdpUrl}/json/new?${encodeURIComponent(url || 'about:blank')}`, { method: 'PUT' })
|
|
50
|
-
const activateTarget = (cdpUrl, id) => httpJSON(`${cdpUrl}/json/activate/${encodeURIComponent(id)}`)
|
|
51
|
-
const closeTarget = (cdpUrl, id) => httpJSON(`${cdpUrl}/json/close/${encodeURIComponent(id)}`)
|
|
52
|
-
|
|
53
|
-
// —— 协议会话 ——
|
|
54
|
-
// 一个 target 一条 ws。call() 按 id 配对回包;on() 收事件(Page.screencastFrame 之类)。
|
|
55
|
-
// 超时 / 断线时把所有挂着的 call 一起拒掉,调用方不会悬着。
|
|
56
|
-
class CdpSession {
|
|
57
|
-
constructor(wsUrl, { WebSocketImpl = null, timeoutMs = 15000 } = {}) {
|
|
58
|
-
this.wsUrl = wsUrl
|
|
59
|
-
this.WebSocketImpl = WebSocketImpl || require('ws')
|
|
60
|
-
this.timeoutMs = timeoutMs
|
|
61
|
-
this.ws = null
|
|
62
|
-
this.connected = false
|
|
63
|
-
this._id = 0
|
|
64
|
-
this._pending = new Map() // id -> { resolve, reject, timer }
|
|
65
|
-
this._handlers = new Map() // method -> Set(fn)
|
|
66
|
-
this._onClose = new Set()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
connect() {
|
|
70
|
-
return new Promise((resolve, reject) => {
|
|
71
|
-
const ws = new this.WebSocketImpl(this.wsUrl)
|
|
72
|
-
this.ws = ws
|
|
73
|
-
const fail = (e) => { if (!this.connected) reject(e instanceof Error ? e : new Error(String(e && e.message || e || '连不上浏览器'))) }
|
|
74
|
-
ws.on('open', () => { this.connected = true; resolve(this) })
|
|
75
|
-
ws.on('error', (e) => { fail(e); this._failAll(e) })
|
|
76
|
-
ws.on('close', () => { this.connected = false; this._failAll(new Error('浏览器连接已断开')); for (const fn of this._onClose) { try { fn() } catch {} } })
|
|
77
|
-
ws.on('message', (data) => this._onMessage(data))
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
_onMessage(data) {
|
|
82
|
-
let m
|
|
83
|
-
try { m = JSON.parse(typeof data === 'string' ? data : data.toString('utf8')) } catch { return }
|
|
84
|
-
if (m.id != null && this._pending.has(m.id)) {
|
|
85
|
-
const p = this._pending.get(m.id); this._pending.delete(m.id); clearTimeout(p.timer)
|
|
86
|
-
if (m.error) p.reject(new Error(`${p.method}: ${m.error.message || JSON.stringify(m.error)}`))
|
|
87
|
-
else p.resolve(m.result || {})
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
if (m.method) {
|
|
91
|
-
const hs = this._handlers.get(m.method)
|
|
92
|
-
if (hs) for (const fn of hs) { try { fn(m.params || {}) } catch (e) { /* 单个处理器出错不影响别的 */ } }
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
_failAll(e) {
|
|
97
|
-
for (const [, p] of this._pending) { clearTimeout(p.timer); p.reject(e) }
|
|
98
|
-
this._pending.clear()
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
call(method, params = {}) {
|
|
102
|
-
if (!this.connected || !this.ws) return Promise.reject(new Error('浏览器连接未就绪'))
|
|
103
|
-
const id = ++this._id
|
|
104
|
-
return new Promise((resolve, reject) => {
|
|
105
|
-
const timer = setTimeout(() => { if (this._pending.delete(id)) reject(new Error(`${method} 超时`)) }, this.timeoutMs)
|
|
106
|
-
this._pending.set(id, { resolve, reject, timer, method })
|
|
107
|
-
try { this.ws.send(JSON.stringify({ id, method, params })) } catch (e) { this._pending.delete(id); clearTimeout(timer); reject(e) }
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
on(method, fn) { if (!this._handlers.has(method)) this._handlers.set(method, new Set()); this._handlers.get(method).add(fn); return () => this.off(method, fn) }
|
|
112
|
-
off(method, fn) { const hs = this._handlers.get(method); if (hs) hs.delete(fn) }
|
|
113
|
-
onClose(fn) { this._onClose.add(fn); return () => this._onClose.delete(fn) }
|
|
114
|
-
|
|
115
|
-
close() { this.connected = false; try { this.ws && this.ws.close() } catch {} ; this._failAll(new Error('已关闭')) }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// —— 截屏推流 ——
|
|
119
|
-
// 帧只在页面重绘时才来(静止页面零流量),所以开流后先补一张 captureScreenshot 当首帧,免得人盯着空白。
|
|
120
|
-
// ack 放在 onFrame 之后:消费方(SSE 写入)慢了,浏览器就攒着不发下一帧 —— 天然背压,不用自己丢帧。
|
|
121
|
-
// 默认值的来由(2026-09-10 真机实测,手机尺寸):640 宽 / 45 质量一帧约 12 KB;everyNthFrame=2 把不限速时的 ~28 fps 压到一半,
|
|
122
|
-
// 再由「onFrame 完成后才 ack」的背压按链路自然降到几帧每秒 —— 画面够顺、公网隧道与手机流量都扛得住。
|
|
123
|
-
async function startScreencast(session, { maxWidth = 640, maxHeight = 1280, quality = 45, everyNthFrame = 2 } = {}, onFrame) {
|
|
124
|
-
await session.call('Page.enable')
|
|
125
|
-
const off = session.on('Page.screencastFrame', async (p) => {
|
|
126
|
-
try { await onFrame({ data: p.data, meta: p.metadata || {}, sessionId: p.sessionId }) } catch {}
|
|
127
|
-
try { await session.call('Page.screencastFrameAck', { sessionId: p.sessionId }) } catch {}
|
|
128
|
-
})
|
|
129
|
-
await session.call('Page.startScreencast', { format: 'jpeg', quality, maxWidth, maxHeight, everyNthFrame })
|
|
130
|
-
try { await seedFrame(session, { maxWidth, maxHeight, quality }, onFrame) } catch { /* 首帧补不上就等真帧 */ }
|
|
131
|
-
return async () => { off(); try { await session.call('Page.stopScreencast') } catch {} }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 补一张首帧(开流时、以及后来才加入同一路推流的订阅者)。
|
|
135
|
-
// ⚠ 首帧必须与推流帧同尺寸:captureScreenshot 默认按视口原尺寸出图,而推流帧被 maxWidth/maxHeight 缩过 ——
|
|
136
|
-
// 两者不一致时,接管页在第二帧到来那一刻画面会跳一下,手指正按着的位置就漂了(2026-09-10 真机:拖滑块位移 0)。
|
|
137
|
-
// 先读视口尺寸,按同样的比例给截图加 clip.scale。
|
|
138
|
-
async function seedFrame(session, { maxWidth = 640, maxHeight = 1280, quality = 45 } = {}, onFrame) {
|
|
139
|
-
const lm = await session.call('Page.getLayoutMetrics')
|
|
140
|
-
const vv = (lm && (lm.cssVisualViewport || lm.visualViewport)) || {}
|
|
141
|
-
const cw = vv.clientWidth || 0, ch = vv.clientHeight || 0
|
|
142
|
-
const scale = cw > 0 && ch > 0 ? Math.min(1, maxWidth / cw, maxHeight / ch) : 1
|
|
143
|
-
const params = { format: 'jpeg', quality }
|
|
144
|
-
if (cw > 0 && ch > 0) params.clip = { x: 0, y: 0, width: cw, height: ch, scale }
|
|
145
|
-
const shot = await session.call('Page.captureScreenshot', params)
|
|
146
|
-
await onFrame({ data: shot.data, meta: { deviceWidth: cw, deviceHeight: ch, seed: true }, sessionId: null })
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 给 CLI 用:截某个标签页一张缩略图(接管卡片上的「卡在哪个页面」)。连一次、截一张、关掉;失败抛错由调用方决定要不要图。
|
|
150
|
-
async function captureTarget(cdpUrl, targetId, { maxWidth = 640, quality = 60, WebSocketImpl = null } = {}) {
|
|
151
|
-
const targets = await listTargets(cdpUrl)
|
|
152
|
-
const t = targets.find((x) => x.id === targetId)
|
|
153
|
-
if (!t) throw new Error('这个标签页已经不在了')
|
|
154
|
-
const session = new CdpSession(t.wsUrl, { WebSocketImpl })
|
|
155
|
-
await session.connect()
|
|
156
|
-
try {
|
|
157
|
-
let data = null
|
|
158
|
-
await seedFrame(session, { maxWidth, maxHeight: maxWidth * 2, quality }, (f) => { data = f.data })
|
|
159
|
-
return { data, title: t.title, url: t.url }
|
|
160
|
-
} finally { session.close() }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// —— 输入:接管页发来的事件 → CDP 调用(纯函数,可测)——
|
|
164
|
-
// 坐标一律是页面 CSS 像素(接管页按帧的 deviceWidth/Height 换算好再发),这里只做校验与翻译。
|
|
165
|
-
const KEYS = {
|
|
166
|
-
Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
|
|
167
|
-
Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
|
|
168
|
-
Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 },
|
|
169
|
-
Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
|
|
170
|
-
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
|
|
171
|
-
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
|
|
172
|
-
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
|
|
173
|
-
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
|
|
174
|
-
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
|
|
175
|
-
Home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
|
|
176
|
-
End: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
|
|
177
|
-
PageUp: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
|
|
178
|
-
PageDown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
|
|
179
|
-
}
|
|
180
|
-
// 一次插入的文本上限:验证码 / 账号 / 一段地址都远小于此;更长的多半是误粘贴(整页文本),不该一股脑塞进输入框
|
|
181
|
-
const MAX_TEXT = 4000
|
|
182
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 10) / 10 : null)
|
|
183
|
-
|
|
184
|
-
// 非文本键必须带 windowsVirtualKeyCode:CDP 只凭 key/code 不会触发页面里按键盘码判断的逻辑(表单的 Enter 提交、退格删字)—— 与 puppeteer 的键表同款。
|
|
185
|
-
// 坐标上限 20000 / 滚轮上限 5000:视口再大也到不了的值,只用来拦明显的垃圾输入(NaN、负数、乱填),不是精确边界。
|
|
186
|
-
// 一个接管页事件 → 若干 { method, params };不认识 / 不合法 ⇒ null(调用方跳过,不报错,免得一个坏事件卡住整批)
|
|
187
|
-
function inputToCdp(ev) {
|
|
188
|
-
if (!ev || typeof ev !== 'object') return null
|
|
189
|
-
const t = ev.t
|
|
190
|
-
if (t === 'text') {
|
|
191
|
-
const text = String(ev.text == null ? '' : ev.text)
|
|
192
|
-
if (!text || text.length > MAX_TEXT) return null
|
|
193
|
-
return [{ method: 'Input.insertText', params: { text } }]
|
|
194
|
-
}
|
|
195
|
-
if (t === 'key') {
|
|
196
|
-
const k = Object.prototype.hasOwnProperty.call(KEYS, String(ev.key)) ? KEYS[ev.key] : null // hasOwn:'__proto__' 之类的键名不能摸到原型
|
|
197
|
-
if (!k) return null
|
|
198
|
-
const mods = (ev.shift ? 8 : 0) | (ev.ctrl ? 2 : 0) | (ev.alt ? 1 : 0) | (ev.meta ? 4 : 0)
|
|
199
|
-
// Enter 的 text 让输入框真的换行 / 提交:keyDown 带 text 才触发 keypress 语义(与 puppeteer 同款)
|
|
200
|
-
const down = { type: k.text ? 'keyDown' : 'rawKeyDown', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode, modifiers: mods }
|
|
201
|
-
if (k.text) down.text = k.text
|
|
202
|
-
return [{ method: 'Input.dispatchKeyEvent', params: down }, { method: 'Input.dispatchKeyEvent', params: { type: 'keyUp', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode, modifiers: mods } }]
|
|
203
|
-
}
|
|
204
|
-
const x = num(ev.x), y = num(ev.y)
|
|
205
|
-
if (x == null || y == null || x < 0 || y < 0 || x > 20000 || y > 20000) return null
|
|
206
|
-
if (t === 'move') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseMoved', x, y, button: ev.down ? 'left' : 'none', buttons: ev.down ? 1 : 0 } }]
|
|
207
|
-
if (t === 'down') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 } }]
|
|
208
|
-
if (t === 'up') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 } }]
|
|
209
|
-
if (t === 'tap') return [
|
|
210
|
-
{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseMoved', x, y, button: 'none', buttons: 0 } },
|
|
211
|
-
{ method: 'Input.dispatchMouseEvent', params: { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 } },
|
|
212
|
-
{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 } },
|
|
213
|
-
]
|
|
214
|
-
if (t === 'wheel') {
|
|
215
|
-
const dx = num(ev.dx) || 0, dy = num(ev.dy) || 0
|
|
216
|
-
if (Math.abs(dx) > 5000 || Math.abs(dy) > 5000) return null
|
|
217
|
-
return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseWheel', x, y, deltaX: dx, deltaY: dy } }]
|
|
218
|
-
}
|
|
219
|
-
return null
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 一批事件按序下发;坏事件跳过(计入 skipped),CDP 报错(页面正在导航之类)记下继续,不中断整批。
|
|
223
|
-
// 超过 max 时只丢 move / wheel(丢掉中间几步无伤),**down / up / key / text 一个都不能丢** —— 丢了 up 鼠标就卡在按下态
|
|
224
|
-
// (对抗审查 🟡7);丢了多少报 dropped,让页面知道。
|
|
225
|
-
function trimEvents(events, max) {
|
|
226
|
-
const list = Array.isArray(events) ? events : []
|
|
227
|
-
if (list.length <= max) return { list, dropped: 0 }
|
|
228
|
-
const must = list.filter((e) => e && e.t !== 'move' && e.t !== 'wheel')
|
|
229
|
-
const room = Math.max(0, max - must.length)
|
|
230
|
-
const soft = list.filter((e) => e && (e.t === 'move' || e.t === 'wheel'))
|
|
231
|
-
const keepSoft = new Set(soft.slice(soft.length - room)) // 留最近的几步,老的丢
|
|
232
|
-
const out = list.filter((e) => e && (e.t !== 'move' && e.t !== 'wheel' ? true : keepSoft.has(e))).slice(0, Math.max(max, must.length))
|
|
233
|
-
return { list: out, dropped: list.length - out.length }
|
|
234
|
-
}
|
|
235
|
-
async function dispatchInputs(session, events, { max = 200 } = {}) {
|
|
236
|
-
const { list, dropped } = trimEvents(events, max)
|
|
237
|
-
let sent = 0, skipped = 0, failed = 0
|
|
238
|
-
for (const ev of list) {
|
|
239
|
-
const calls = inputToCdp(ev)
|
|
240
|
-
if (!calls) { skipped++; continue }
|
|
241
|
-
for (const c of calls) { try { await session.call(c.method, c.params); sent++ } catch { failed++ } }
|
|
242
|
-
}
|
|
243
|
-
return { sent, skipped, failed, dropped }
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// 接管页 → 页面坐标:帧图片的像素坐标按帧元数据里的视口尺寸(CSS px)线性换算;越界夹回
|
|
247
|
-
function pointerToCss({ px, py, imgW, imgH, cssW, cssH }) {
|
|
248
|
-
if (!(imgW > 0) || !(imgH > 0) || !(cssW > 0) || !(cssH > 0)) return null
|
|
249
|
-
const x = Math.min(cssW, Math.max(0, (px / imgW) * cssW))
|
|
250
|
-
const y = Math.min(cssH, Math.max(0, (py / imgH) * cssH))
|
|
251
|
-
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
module.exports = { DEFAULT_CDP_URL, httpJSON, versionInfo, listTargets, newTarget, activateTarget, closeTarget, CdpSession, startScreencast, seedFrame, captureTarget, inputToCdp, dispatchInputs, trimEvents, pointerToCss, KEYS, MAX_TEXT }
|
package/src/cli-secret.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Mask characters echoed by a terminal readline prompt while a secret is being entered.
|
|
2
|
-
// Readline's _writeToOutput is the single output path for terminal echo; prompts and
|
|
3
|
-
// cursor-control sequences stay intact so paste, backspace and arrow-key editing work.
|
|
4
|
-
|
|
5
|
-
const CONTROL = /(\x1b\[[0-?]*[ -/]*[@-~]|\x1b[()][A-Za-z0-9]|\x08 \x08|[\x00-\x1f\x7f])/g
|
|
6
|
-
const CONTROL_TOKEN = /^(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b[()][A-Za-z0-9]|\x08 \x08|[\x00-\x1f\x7f])$/
|
|
7
|
-
|
|
8
|
-
function maskReadlineEcho(text) {
|
|
9
|
-
return String(text).split(CONTROL).map((part) => {
|
|
10
|
-
if (!part || CONTROL_TOKEN.test(part)) return part
|
|
11
|
-
return '•'.repeat(Array.from(part).length)
|
|
12
|
-
}).join('')
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function createReadlineSecretMask(rl) {
|
|
16
|
-
if (!rl || typeof rl._writeToOutput !== 'function') throw new TypeError('readline interface required')
|
|
17
|
-
const original = rl._writeToOutput
|
|
18
|
-
let activePrompt = null
|
|
19
|
-
|
|
20
|
-
rl._writeToOutput = function (text) {
|
|
21
|
-
if (activePrompt === null) return original.call(this, text)
|
|
22
|
-
text = String(text)
|
|
23
|
-
const promptAt = text.lastIndexOf(activePrompt)
|
|
24
|
-
if (promptAt < 0) return original.call(this, maskReadlineEcho(text))
|
|
25
|
-
const promptEnd = promptAt + activePrompt.length
|
|
26
|
-
return original.call(this, text.slice(0, promptEnd) + maskReadlineEcho(text.slice(promptEnd)))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
start(prompt) { activePrompt = String(prompt) },
|
|
31
|
-
stop() { activePrompt = null },
|
|
32
|
-
restore() { activePrompt = null; rl._writeToOutput = original },
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
module.exports = { maskReadlineEcho, createReadlineSecretMask }
|
package/src/clilang.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// 命令行输出语言(安装向导、服务安装这些新用户第一眼看到的输出;其余命令仍是中文)。
|
|
2
|
-
// 判据与 install.sh 的 detect_lang 一致:SINTBOT_LANG=zh|en 优先;否则 locale(LC_ALL / LC_MESSAGES / LANG)
|
|
3
|
-
// 或 macOS 系统语言(AppleLanguages 第一项)有一处是中文就用中文,其余英文。
|
|
4
|
-
// 为什么看系统语言:不少人在 ~/.zshenv 里写死 LANG=en_US.UTF-8(工具链兼容),界面却是中文 ——
|
|
5
|
-
// 1.0.11 实装时安装脚本因此出英文、向导是中文,同一屏两种语言。
|
|
6
|
-
const { spawnSync } = require('node:child_process')
|
|
7
|
-
|
|
8
|
-
let macCache
|
|
9
|
-
function macLanguage({ platform = process.platform, run = spawnSync } = {}) {
|
|
10
|
-
if (platform !== 'darwin') return ''
|
|
11
|
-
if (macCache !== undefined) return macCache
|
|
12
|
-
try {
|
|
13
|
-
const r = run('defaults', ['read', '-g', 'AppleLanguages'], { encoding: 'utf8', timeout: 2000 })
|
|
14
|
-
const m = String((r && r.stdout) || '').match(/"?([A-Za-z]{2,3})[-_A-Za-z]*"?/)
|
|
15
|
-
macCache = m ? m[1].toLowerCase() : ''
|
|
16
|
-
} catch { macCache = '' }
|
|
17
|
-
return macCache
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function cliLang({ env = process.env, platform = process.platform, run } = {}) {
|
|
21
|
-
const forced = String(env.SINTBOT_LANG || '').toLowerCase()
|
|
22
|
-
if (forced === 'zh' || forced === 'en') return forced
|
|
23
|
-
const locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || ''
|
|
24
|
-
if (/^zh/i.test(locale)) return 'zh'
|
|
25
|
-
return macLanguage({ platform, run }) === 'zh' ? 'zh' : 'en'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// tr('中文', 'English') —— 按当前语言取一句
|
|
29
|
-
const tr = (zh, en, lang = cliLang()) => (lang === 'zh' ? zh : en)
|
|
30
|
-
|
|
31
|
-
module.exports = { cliLang, tr, _resetCache: () => { macCache = undefined } }
|
package/src/core/cron.js
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// Minimal cron parser + matcher (zero-dep) for the 定时任务 base skill.
|
|
2
|
-
//
|
|
3
|
-
// Standard 5-field cron — "minute hour day-of-month month day-of-week" — plus @aliases
|
|
4
|
-
// (@daily/@hourly/…) and a few Chinese shorthands ("每天 09:00", "工作日 8:30"). Matching
|
|
5
|
-
// uses PRODUCT-TIMEZONE parts (Asia/Shanghai default, see time.js) so a task fires at the
|
|
6
|
-
// wall-clock the user meant, regardless of the host's system timezone.
|
|
7
|
-
//
|
|
8
|
-
// Field syntax per position: `*`, `a`, `a-b`, `*/n`, `a/n`, `a-b/n`, and comma lists thereof.
|
|
9
|
-
// day-of-week: 0=Sunday..6=Saturday. dom+dow both restricted ⇒ match EITHER (standard cron quirk).
|
|
10
|
-
|
|
11
|
-
const { tzParts } = require('./time')
|
|
12
|
-
|
|
13
|
-
const ALIASES = {
|
|
14
|
-
'@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *',
|
|
15
|
-
'@monthly': '0 0 1 * *', '@weekly': '0 0 * * 0',
|
|
16
|
-
'@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const ZH_DOW = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 0, 天: 0 }
|
|
20
|
-
|
|
21
|
-
// A few human shorthands → a 5-field cron string, or null if it isn't one we recognize.
|
|
22
|
-
function zhShorthand(s) {
|
|
23
|
-
const t = String(s).trim()
|
|
24
|
-
let m
|
|
25
|
-
if ((m = t.match(/^每天\s*(\d{1,2})[::](\d{1,2})$/))) return `${+m[2]} ${+m[1]} * * *`
|
|
26
|
-
if ((m = t.match(/^工作日\s*(\d{1,2})[::](\d{1,2})$/))) return `${+m[2]} ${+m[1]} * * 1-5`
|
|
27
|
-
if ((m = t.match(/^每周([一二三四五六日天])\s*(\d{1,2})[::](\d{1,2})$/))) return `${+m[3]} ${+m[2]} * * ${ZH_DOW[m[1]]}`
|
|
28
|
-
if (/^每小时$/.test(t)) return '0 * * * *'
|
|
29
|
-
return null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Normalize any accepted form to a validated 5-field string, or throw with a human message.
|
|
33
|
-
function normalizeCron(expr) {
|
|
34
|
-
if (typeof expr !== 'string' || !expr.trim()) throw new Error('cron 表达式为空')
|
|
35
|
-
let s = expr.trim()
|
|
36
|
-
if (ALIASES[s.toLowerCase()]) s = ALIASES[s.toLowerCase()]
|
|
37
|
-
else { const zh = zhShorthand(s); if (zh) s = zh }
|
|
38
|
-
const parts = s.split(/\s+/)
|
|
39
|
-
if (parts.length !== 5) throw new Error(`cron 需要 5 段(分 时 日 月 周),或 @别名/中文简写(如「每天 09:00」),得到:${expr}`)
|
|
40
|
-
const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]]
|
|
41
|
-
parts.forEach((f, i) => parseField(f, ranges[i][0], ranges[i][1])) // throws on a bad field
|
|
42
|
-
return parts.join(' ')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Parse ONE field into a Set of allowed integers, or null for '*' (matches anything).
|
|
46
|
-
function parseField(f, min, max) {
|
|
47
|
-
if (f === '*') return null
|
|
48
|
-
const out = new Set()
|
|
49
|
-
for (const part of String(f).split(',')) {
|
|
50
|
-
const m = part.match(/^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/)
|
|
51
|
-
if (!m) throw new Error(`cron 字段无法解析:${part}`)
|
|
52
|
-
const step = m[3] ? +m[3] : 1
|
|
53
|
-
let lo, hi
|
|
54
|
-
if (m[1] === '*') { lo = min; hi = max }
|
|
55
|
-
else { lo = +m[1]; hi = m[2] != null ? +m[2] : (m[3] ? max : +m[1]) } // `a/n` runs a→max
|
|
56
|
-
if (lo < min || hi > max || lo > hi || step < 1) throw new Error(`cron 字段越界/非法:${part}`)
|
|
57
|
-
for (let v = lo; v <= hi; v += step) out.add(v)
|
|
58
|
-
}
|
|
59
|
-
return out
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Does the (normalized-or-raw) expr fire at instant `ts` interpreted in timezone `tz`?
|
|
63
|
-
function matchCron(expr, ts, tz) {
|
|
64
|
-
const [fMin, fHour, fDom, fMon, fDow] = normalizeCron(expr).split(' ')
|
|
65
|
-
const p = tzParts(ts, tz)
|
|
66
|
-
const has = (field, val, min, max) => { const set = parseField(field, min, max); return set === null || set.has(val) }
|
|
67
|
-
// day-of-week from the tz-local calendar date (weekday is timezone-agnostic once y/m/d fixed)
|
|
68
|
-
const dow = new Date(Date.UTC(+p.year, +p.month - 1, +p.day)).getUTCDay() // 0=Sun..6=Sat
|
|
69
|
-
const domOk = has(fDom, +p.day, 1, 31), dowOk = has(fDow, dow, 0, 6)
|
|
70
|
-
const dayOk = (fDom !== '*' && fDow !== '*') ? (domOk || dowOk) : (domOk && dowOk)
|
|
71
|
-
return has(fMin, +p.minute, 0, 59) && has(fHour, +p.hour, 0, 23) && has(fMon, +p.month, 1, 12) && dayOk
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Human-readable Chinese summary of a cron expr for the console list ("0 9 * * 1-5" → "工作日
|
|
75
|
-
// 09:00"). Covers the common schedules; anything richer falls back to the normalized cron string.
|
|
76
|
-
const ZH_DOW_NAME = ['日', '一', '二', '三', '四', '五', '六']
|
|
77
|
-
function describeCron(expr) {
|
|
78
|
-
let norm
|
|
79
|
-
try { norm = normalizeCron(expr) } catch { return String(expr) }
|
|
80
|
-
const [mi, h, dom, mon, dow] = norm.split(' ')
|
|
81
|
-
const single = (f) => /^\d+$/.test(f)
|
|
82
|
-
const hhmm = (H, M) => `${String(H).padStart(2, '0')}:${String(M).padStart(2, '0')}`
|
|
83
|
-
if (norm === '0 * * * *') return '每小时整点'
|
|
84
|
-
if (/^\*\/\d+$/.test(mi) && h === '*' && dom === '*' && mon === '*' && dow === '*') return `每 ${mi.slice(2)} 分钟`
|
|
85
|
-
if (single(mi) && single(h) && mon === '*') {
|
|
86
|
-
const t = hhmm(+h, +mi)
|
|
87
|
-
if (dom === '*' && dow === '*') return `每天 ${t}`
|
|
88
|
-
if (dom === '*' && dow === '1-5') return `工作日 ${t}`
|
|
89
|
-
if (dom === '*' && (dow === '0,6' || dow === '6,0')) return `周末 ${t}`
|
|
90
|
-
if (dom === '*' && single(dow)) return `每周${ZH_DOW_NAME[+dow]} ${t}`
|
|
91
|
-
if (dom === '*' && /^[0-6](,[0-6])+$/.test(dow)) return `每周${dow.split(',').map((d) => ZH_DOW_NAME[+d]).join('、')} ${t}`
|
|
92
|
-
if (single(dom) && dow === '*') return `每月 ${+dom} 号 ${t}`
|
|
93
|
-
}
|
|
94
|
-
// Fixed month+day (e.g. "10 13 16 7 *"): standard cron has no year, so this recurs EVERY year.
|
|
95
|
-
if (single(mi) && single(h) && single(mon) && single(dom) && dow === '*') return `每年 ${+mon} 月 ${+dom} 日 ${hhmm(+h, +mi)}`
|
|
96
|
-
return norm // fallback: the normalized cron itself
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ── One-shot tasks (一次性): stored as `at: "YYYY-MM-DD HH:mm"`, interpreted in the group's
|
|
100
|
-
// timezone. Standard cron has no year, so a true one-off needs an absolute datetime. The scheduler
|
|
101
|
-
// fires it in the matching minute; the daemon deletes it right after (fire-once, then gone).
|
|
102
|
-
function normalizeAt(s) {
|
|
103
|
-
const m = String(s).trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})$/)
|
|
104
|
-
if (!m) throw new Error('一次性时间格式应为 YYYY-MM-DD HH:mm(如 2026-07-16 13:10)')
|
|
105
|
-
const mo = +m[2], d = +m[3], h = +m[4], mi = +m[5]
|
|
106
|
-
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) throw new Error('一次性时间越界:' + s)
|
|
107
|
-
return `${m[1]}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')} ${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Fires when the tz-local wall-clock minute equals `at`.
|
|
111
|
-
function matchAt(at, ts, tz) {
|
|
112
|
-
const p = tzParts(ts, tz)
|
|
113
|
-
return normalizeAt(at) === `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Human string for a one-off. Relative to `nowStr` (the group-tz present as "YYYY-MM-DD HH:mm"):
|
|
117
|
-
// same day ⇒ just "13:10"; same year ⇒ "7月16日 13:10"; else full "2026年7月16日 13:10". Omit
|
|
118
|
-
// `nowStr` ⇒ always full absolute (keeps the fn pure/testable; callers that know "now" pass it).
|
|
119
|
-
// The "一次" tag is added by the UI badge to avoid duplication.
|
|
120
|
-
function describeAt(at, nowStr) {
|
|
121
|
-
let m
|
|
122
|
-
try { m = normalizeAt(at).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2})$/) } catch { return String(at) }
|
|
123
|
-
const [, y, mo, d, hm] = m
|
|
124
|
-
const n = /^(\d{4})-(\d{2})-(\d{2}) /.exec(nowStr || '')
|
|
125
|
-
if (n) {
|
|
126
|
-
if (n[1] === y && n[2] === mo && n[3] === d) return hm // 今天 → 只留时间
|
|
127
|
-
if (n[1] === y) return `${+mo}月${+d}日 ${hm}` // 今年 → 省年份
|
|
128
|
-
}
|
|
129
|
-
return `${y}年${+mo}月${+d}日 ${hm}` // 跨年 / 无 now → 完整
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Unified label: one-off (`at`, relative to nowStr) or recurring (`expr`, already relative-free).
|
|
133
|
-
function describeTask(task, nowStr) {
|
|
134
|
-
if (!task) return ''
|
|
135
|
-
return task.at ? describeAt(task.at, nowStr) : describeCron(task.expr)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Expand a one-off shorthand into an absolute "YYYY-MM-DD HH:mm", filling the missing leading
|
|
139
|
-
// parts from the group-tz present (the mirror of describeAt's omission). Accepts:
|
|
140
|
-
// "HH:mm" → today (rolls to tomorrow if that minute already passed,
|
|
141
|
-
// so a one-off is never born already-expired)
|
|
142
|
-
// "MM-DD HH:mm" / "M月D日 HH:mm" → this year, that month/day
|
|
143
|
-
// full "YYYY-MM-DD HH:mm" / "YYYY年M月D日 HH:mm" → taken as given
|
|
144
|
-
// `nowMs`/`tz` locate today/tomorrow in the group timezone. Throws on unparseable input. The
|
|
145
|
-
// STORED value is always absolute, so matchAt / expiry / scheduler stay unchanged.
|
|
146
|
-
function expandAt(input, nowMs, tz) {
|
|
147
|
-
const s = String(input || '').trim()
|
|
148
|
-
if (/^\d{4}-\d{1,2}-\d{1,2}[ T]\d{1,2}:\d{2}$/.test(s)) return normalizeAt(s)
|
|
149
|
-
const full = s.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})$/)
|
|
150
|
-
if (full) return normalizeAt(`${full[1]}-${full[2]}-${full[3]} ${full[4]}:${full[5]}`)
|
|
151
|
-
const now = tzParts(nowMs, tz)
|
|
152
|
-
const timeOnly = s.match(/^(\d{1,2}):(\d{2})$/)
|
|
153
|
-
if (timeOnly) {
|
|
154
|
-
const h = +timeOnly[1], mi = +timeOnly[2]
|
|
155
|
-
if (h > 23 || mi > 59) throw new Error('时间越界:' + s)
|
|
156
|
-
const hm = `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`
|
|
157
|
-
let { year, month, day } = now
|
|
158
|
-
// 已过当前分钟 ⇒ 顺延到明天(+24h 后重取 tz 日期,跨月/跨年由 tzParts 正确处理)
|
|
159
|
-
if (hm <= `${now.hour}:${now.minute}`) { const t = tzParts(nowMs + 864e5, tz); year = t.year; month = t.month; day = t.day }
|
|
160
|
-
return normalizeAt(`${year}-${month}-${day} ${hm}`)
|
|
161
|
-
}
|
|
162
|
-
const md = s.match(/^(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})$/) || s.match(/^(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})$/)
|
|
163
|
-
if (md) return normalizeAt(`${now.year}-${md[1]}-${md[2]} ${md[3]}:${md[4]}`)
|
|
164
|
-
throw new Error('时间无法识别:' + s + '(可填 10:20 / 07-16 10:20 / 2026-07-16 10:20)')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Flatten per-group crons into a scheduler work-list, tagging each with its chatId. Shape on disk:
|
|
168
|
-
// groups[chatId].crons = [{ id, expr|at, kind:'do'|'say'|'run', text, enabled, once?, note?, resetSession?, timeoutMin?, escalate? }].
|
|
169
|
-
// kind:'run'(v0.11.103):text 是一条 shell 命令,到点由 daemon 在项目根直接跑,不起 agent(src/core/cronrun.js);
|
|
170
|
-
// timeoutMin 超时上限(默认 15 分钟)· escalate 失败时把输出交给 agent 排查(默认只在群里提示一句)。
|
|
171
|
-
// resetSession(v0.11.19,operator 配置层字段):该任务的轮**成功结算后**开新会话 —— 给「整理
|
|
172
|
-
// 落盘」类任务用,交接文书就是任务刚写完的文档;失败/被叫停不翻篇。engine 侧消费,这里只透传。
|
|
173
|
-
function collectCrons(cfg) {
|
|
174
|
-
const out = []
|
|
175
|
-
for (const [chatId, g] of Object.entries((cfg && cfg.groups) || {})) {
|
|
176
|
-
const list = g && Array.isArray(g.crons) ? g.crons : []
|
|
177
|
-
for (const c of list) if (c && c.id) out.push({ ...c, chatId })
|
|
178
|
-
}
|
|
179
|
-
return out
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
module.exports = { normalizeCron, parseField, matchCron, zhShorthand, describeCron, normalizeAt, matchAt, describeAt, describeTask, expandAt, collectCrons, ALIASES }
|
package/src/core/cronfire.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// 定时任务到点做什么(v0.11.103 从 bin 的 scheduler.fire 抽出来):三种任务各走各的路,依赖全部注入 ⇒ 可单测。
|
|
2
|
-
// do → 构造合成消息喂 engine(复用群路由 / 锁 / 权限 / 卡片全套管线,群间互不干扰)
|
|
3
|
-
// say → 直接发一条消息(过 redact),不起 agent
|
|
4
|
-
// run → daemon 直接跑命令(src/core/cronrun.js),不起 agent、不占群锁、不进会话;成功只留日志 + 账本一行,
|
|
5
|
-
// 失败群里一句人话附输出末尾,task.escalate 时改为把输出交给 agent 排查
|
|
6
|
-
// 直发的消息(say 正文 / 脚本失败提示)发不出去时**补发**,不是丢掉:09-17 21:00 一次约 5 分钟的断网里,脚本
|
|
7
|
-
// 失败提示恰好也发不出去(平台层只记一行日志就把 null 吞了),群里对那次失败一无所知。断网正是失败最常见的
|
|
8
|
-
// 原因 ⇒ 「失败提示」在最需要它的时候最容易丢。补发按 1 / 5 / 15 / 30 分钟四次,正文前加一行「补发 · 原定几点」,
|
|
9
|
-
// 四次都不成才放弃并记日志;同一条消息再次触发时只换正文不叠加。
|
|
10
|
-
// 为什么抽出来:这段判断原来写在调用方(bin)里,单测只测得到被它调用的纯函数 —— 「A 必须传到 B」的接线
|
|
11
|
-
// 从来没被钉住(v0.11.54 那版逮到三条同形的漏)。现在 fire 本身就是被测对象。
|
|
12
|
-
// ⚠ core 保持无感:这里不认平台名、不认 runner 名,只认注入进来的函数。
|
|
13
|
-
const { runScriptTask, runOk, runReason, outputTail, runFailureNotice, escalationPrompt, DEFAULT_TIMEOUT_MS } = require('./cronrun')
|
|
14
|
-
const { fmtStamp } = require('./time')
|
|
15
|
-
|
|
16
|
-
// 直发消息发不出去时的补发间隔:1 / 5 / 15 / 30 分钟(累计约 51 分钟)。09-17 那次断网 5 分钟,第二次就能补上;
|
|
17
|
-
// 更长的断网多半是机器本身出事,那时群里该由另一台的看门告警说话,不靠这里无限重试。
|
|
18
|
-
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000]
|
|
19
|
-
|
|
20
|
-
// deps:
|
|
21
|
-
// getGroup(chatId) → 群配置(paused / workdir);tz() → 产品时区;handleMessage(msg) → engine 入口;
|
|
22
|
-
// send(chatId, text) → 平台直发(已 redact);redact(s);log(s);ledger.append(row);removeTask(chatId, id) → 一次性任务触发后删;
|
|
23
|
-
// runScript(opts) → 可注入(测试用假 spawn);now();env → 脚本继承的环境(对象,或 (task) => 对象:按群注入 SBOT_CHAT_ID 等);
|
|
24
|
-
// cwdFallback → 群没配 workdir 时的 cwd;
|
|
25
|
-
// send 的返回值契约(interfaces.js):resolve 成 **null = 平台确认没送到**(抛错同义);其余值(message_id / undefined)= 已送到或不跟踪。
|
|
26
|
-
// retryDelaysMs → 补发间隔表(测试注入短的);setTimer / clearTimer → 定时器(测试注入假的)。
|
|
27
|
-
function createCronFire(deps) {
|
|
28
|
-
const { getGroup, tz, handleMessage, send, redact = (s) => s, log = () => {}, ledger = null, removeTask = () => {}, runScript = runScriptTask, now = Date.now, env = process.env, cwdFallback = process.cwd(), retryDelaysMs = RETRY_DELAYS_MS, setTimer = setTimeout, clearTimer = clearTimeout } = deps
|
|
29
|
-
const running = new Set() // 正在跑的脚本任务(chatId:id):同一任务不并行(小时级巡检 + 挂死脚本 = 无限堆进程)
|
|
30
|
-
const children = new Map() // key → 子进程:关停时要能杀(审查逮到:daemon 退了脚本成孤儿,下次写 stdout 就 EPIPE 死、无人报)
|
|
31
|
-
const undelivered = new Map() // 待补发的直发消息:key(chatId:id:用途) → { chatId, text, firstAt, tries, timer }
|
|
32
|
-
|
|
33
|
-
// 直发一条,发不出去就排补发。key 按「任务 + 用途」去重:同一任务连着两次失败只留最新那条正文,不让群里收到一串。
|
|
34
|
-
const deliver = (key, chatId, text) => {
|
|
35
|
-
const prev = undelivered.get(key)
|
|
36
|
-
if (prev) { if (prev.timer) clearTimer(prev.timer); undelivered.delete(key) }
|
|
37
|
-
const rec = { chatId, text, firstAt: (prev && prev.firstAt) || now(), tries: 0, timer: null }
|
|
38
|
-
return attempt(key, rec)
|
|
39
|
-
}
|
|
40
|
-
const attempt = (key, rec) => {
|
|
41
|
-
// 补发时正文前加一行说明:读的人得知道这是几点的事,不然「没跑成功」四个字在 30 分钟后看到会误判成又失败了一次
|
|
42
|
-
const body = rec.tries > 0 ? `(补发 · 原定 ${fmtStamp(rec.firstAt, tz())} 发出,当时发不出去)\n` + rec.text : rec.text
|
|
43
|
-
return new Promise((resolve) => resolve(send(rec.chatId, body))).then((r) => r === null ? Promise.reject(new Error('平台没送到')) : r).then(() => {
|
|
44
|
-
undelivered.delete(key)
|
|
45
|
-
if (rec.tries > 0) log(`cron 消息补发成功(第 ${rec.tries} 次):${key}`)
|
|
46
|
-
}, (e) => {
|
|
47
|
-
const delay = retryDelaysMs[rec.tries]
|
|
48
|
-
if (delay === undefined) { undelivered.delete(key); log(`cron 消息放弃补发(${rec.tries} 次都发不出去):${key} — ` + (e && e.message)); return }
|
|
49
|
-
rec.tries++
|
|
50
|
-
undelivered.set(key, rec)
|
|
51
|
-
log(`cron 消息发不出去(${e && e.message}),${Math.round(delay / 60000)} 分钟后补发(第 ${rec.tries} 次):${key}`)
|
|
52
|
-
rec.timer = setTimer(() => { rec.timer = null; attempt(key, rec) }, delay)
|
|
53
|
-
if (rec.timer && typeof rec.timer.unref === 'function') rec.timer.unref() // 补发不该拖住进程退出
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const synthetic = (task, text, extra = {}) => {
|
|
58
|
-
const t = now()
|
|
59
|
-
// cron:true ⇒ 群里正忙时的排队提示走定时任务那句(engine T.queuedCron)
|
|
60
|
-
return { chatId: task.chatId, userId: 'cron', senderName: '定时任务', synthetic: true, cron: true, text, msgId: `cron:${task.chatId}:${task.id}:${t}`, createTimeMs: t, replyCtx: { chatId: task.chatId }, ...extra }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const fireRun = (task, group) => {
|
|
64
|
-
const key = `${task.chatId}:${task.id}`
|
|
65
|
-
if (running.has(key)) { log(`cron 脚本跳过(上一次还没跑完): ${key}`); return Promise.resolve() }
|
|
66
|
-
running.add(key)
|
|
67
|
-
const startedAt = now()
|
|
68
|
-
const cwd = (group && group.workdir) || cwdFallback
|
|
69
|
-
const timeoutMs = task.timeoutMin > 0 ? task.timeoutMin * 60_000 : DEFAULT_TIMEOUT_MS
|
|
70
|
-
// ⚠ new Promise 包一层:runScript 同步抛(spawn 本身炸了)也要走下面的 catch 释放 running 占位 ——
|
|
71
|
-
// Promise.resolve(f()) 那种写法里 f 同步抛会绕过 catch,占位永远不放,这个任务从此每次都「上一次还没跑完」。
|
|
72
|
-
// env:脚本要拿到与 agent 子进程同一组基础能力变量(SBOT_CHAT_ID / SBOT_CONFIG / …,bin 里传 engine.baseSkillEnv),
|
|
73
|
-
// 否则脚本里的 `sbot feishu send-msg` 不知道发到哪个群(09-17 18:00 实证:退出码 1「缺少目标群」)。
|
|
74
|
-
const scriptEnv = typeof env === 'function' ? env(task) : env
|
|
75
|
-
return new Promise((resolve) => resolve(runScript({ command: String(task.text || ''), cwd, env: { ...scriptEnv, TZ: tz() }, timeoutMs, onChild: (c) => children.set(key, c) }))).then((res) => {
|
|
76
|
-
running.delete(key); children.delete(key)
|
|
77
|
-
const ok = runOk(res)
|
|
78
|
-
log(`cron 脚本 ${key} ${ok ? 'ok' : '失败(' + runReason(res) + ')'} · ${Math.round(res.ms / 1000)}s` + (ok ? '' : '\n' + outputTail(res)))
|
|
79
|
-
// 账本一行(与轮次同一本;script:true 区分 —— 它没有模型、没有 token,读账本的人别把它当轮次算)
|
|
80
|
-
if (ledger) ledger.append({ ts: startedAt, chatId: task.chatId, provider: null, sender: '定时任务', text: String(task.text || '').slice(0, 200), status: ok ? 'ok' : 'error', ms: res.ms, tokens: null, script: true, ...(ok ? {} : { err: runReason(res) }) })
|
|
81
|
-
if (ok) return
|
|
82
|
-
if (task.escalate) return handleMessage(synthetic(task, escalationPrompt(task, res)))
|
|
83
|
-
return deliver(`${key}:fail`, task.chatId, redact(runFailureNotice(task, res)))
|
|
84
|
-
}).catch((e) => { running.delete(key); children.delete(key); log('cron 脚本执行异常: ' + (e && e.message)) })
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 返回 Promise 只为测试能等到收尾;调度器不等它(fire-and-forget)。
|
|
88
|
-
function fire(task) {
|
|
89
|
-
try {
|
|
90
|
-
// 暂停群到点跳过(不补触发,与「错过不触发」同语义)—— --do 烧额度、--say / --run 也是打扰
|
|
91
|
-
const group = getGroup(task.chatId)
|
|
92
|
-
if (group && group.paused) { log(`cron 跳过(群已暂停): ${task.chatId} ${task.id}`); return Promise.resolve() }
|
|
93
|
-
let p
|
|
94
|
-
if (task.kind === 'say') p = deliver(`${task.chatId}:${task.id}:say`, task.chatId, redact(String(task.text || '')))
|
|
95
|
-
else if (task.kind === 'run') p = fireRun(task, group)
|
|
96
|
-
else p = Promise.resolve(handleMessage(synthetic(task, String(task.text || ''), { resetSessionAfter: !!task.resetSession })))
|
|
97
|
-
if (task.once || task.at) removeTask(task.chatId, task.id) // 一次性:触发后即删,列表不堆积
|
|
98
|
-
return p
|
|
99
|
-
} catch (e) { log('cron fire 失败: ' + (e && e.message)); return Promise.resolve() }
|
|
100
|
-
}
|
|
101
|
-
// 关停用:在跑的脚本数 / 杀掉全部(整棵进程组)。探空闲按 daemon 子进程数已能看见它们(detached 只换进程组,父仍是 daemon)。
|
|
102
|
-
fire.inFlight = () => running.size
|
|
103
|
-
fire.undelivered = () => undelivered.size // 待补发的直发消息数(关停 / 排障看)
|
|
104
|
-
fire.abortAll = () => {
|
|
105
|
-
// 待补发的一并放弃:进程都要退了,定时器留着也没人执行;记日志让排障的人知道丢了几条
|
|
106
|
-
for (const [key, rec] of undelivered) { if (rec.timer) clearTimer(rec.timer); log(`关停:放弃补发 ${key}`) }
|
|
107
|
-
undelivered.clear()
|
|
108
|
-
let n = 0
|
|
109
|
-
for (const [key, c] of children) {
|
|
110
|
-
n++
|
|
111
|
-
try { if (process.platform === 'win32') require('node:child_process').spawn('taskkill', ['/pid', String(c.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }); else process.kill(-c.pid, 'SIGKILL') } catch { try { c.kill('SIGKILL') } catch {} }
|
|
112
|
-
log(`关停:杀掉在跑的定时脚本 ${key}`)
|
|
113
|
-
}
|
|
114
|
-
children.clear(); running.clear()
|
|
115
|
-
return n
|
|
116
|
-
}
|
|
117
|
-
return fire
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
module.exports = { createCronFire, RETRY_DELAYS_MS }
|