@dnalec/dsh-auto-approve 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/README.zh.md +152 -0
- package/client.js +1312 -0
- package/cordis.patch.yml +5 -0
- package/locales.mjs +398 -0
- package/package.json +65 -0
- package/src/index.mjs +1143 -0
- package/src/preset-patch.mjs +164 -0
- package/src/provisioning.mjs +131 -0
- package/src/qqbot.mjs +313 -0
- package/src/rules.mjs +900 -0
- package/src/tickets.mjs +277 -0
- package/src/util.mjs +242 -0
package/src/tickets.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 短号票据:#1–#99 循环;解析 QQ 批复;结案 30s 记忆。
|
|
3
|
+
*
|
|
4
|
+
* 网页与 QQ 竞速:先答的 outcome 生效。结案后 30s 内同一短号再答会提示「已处理」,
|
|
5
|
+
* 避免用户点两次按钮以为没生效。没有「永久拒绝」。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX = 99
|
|
9
|
+
|
|
10
|
+
/** 进程内经纪。不落盘:重启后未结票据作废,网页框也会随会话消失。 */
|
|
11
|
+
export function createTicketBroker() {
|
|
12
|
+
/** @type {Map<number, object>} */
|
|
13
|
+
const pending = new Map()
|
|
14
|
+
/** @type {Map<number, { ts: number, outcome: string }>} */
|
|
15
|
+
const recent = new Map()
|
|
16
|
+
let nextNum = 1
|
|
17
|
+
|
|
18
|
+
function pruneRecent(now = Date.now()) {
|
|
19
|
+
for (const [n, rec] of recent) {
|
|
20
|
+
if (now - rec.ts > 30_000) recent.delete(n)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function allocate(meta) {
|
|
25
|
+
pruneRecent()
|
|
26
|
+
for (let i = 0; i < MAX; i++) {
|
|
27
|
+
const n = ((nextNum - 1 + i) % MAX) + 1
|
|
28
|
+
if (pending.has(n)) continue
|
|
29
|
+
nextNum = (n % MAX) + 1
|
|
30
|
+
const ticket = {
|
|
31
|
+
n,
|
|
32
|
+
sessionId: meta.sessionId || '',
|
|
33
|
+
callId: meta.callId || '',
|
|
34
|
+
toolName: meta.toolName || '',
|
|
35
|
+
mode: meta.mode || '',
|
|
36
|
+
reason: meta.reason || '',
|
|
37
|
+
justification: meta.justification || '',
|
|
38
|
+
category: meta.category || '',
|
|
39
|
+
judgeReason: meta.judgeReason || '',
|
|
40
|
+
path: meta.path || '',
|
|
41
|
+
cwd: meta.cwd || '',
|
|
42
|
+
args: meta.args && typeof meta.args === 'object' ? meta.args : {},
|
|
43
|
+
resolve: null,
|
|
44
|
+
settled: false,
|
|
45
|
+
}
|
|
46
|
+
const wait = new Promise((resolve) => {
|
|
47
|
+
ticket.resolve = (result) => {
|
|
48
|
+
if (ticket.settled) return
|
|
49
|
+
ticket.settled = true
|
|
50
|
+
pending.delete(n)
|
|
51
|
+
recent.set(n, { ts: Date.now(), outcome: result && result.outcome })
|
|
52
|
+
resolve(result)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
ticket.wait = wait
|
|
56
|
+
pending.set(n, ticket)
|
|
57
|
+
return ticket
|
|
58
|
+
}
|
|
59
|
+
throw new Error('no free ticket numbers')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function get(n) {
|
|
63
|
+
return pending.get(Number(n))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listPending() {
|
|
67
|
+
return [...pending.values()].map((t) => ({
|
|
68
|
+
n: t.n,
|
|
69
|
+
sessionId: t.sessionId,
|
|
70
|
+
toolName: t.toolName,
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function answer(n, result) {
|
|
75
|
+
const ticket = pending.get(Number(n))
|
|
76
|
+
if (!ticket) return false
|
|
77
|
+
ticket.resolve({
|
|
78
|
+
source: 'qq',
|
|
79
|
+
outcome: result.outcome,
|
|
80
|
+
})
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function discard(n, reason) {
|
|
85
|
+
const ticket = pending.get(Number(n))
|
|
86
|
+
if (!ticket) return
|
|
87
|
+
ticket.resolve({ source: reason || 'discard', outcome: null })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recentOutcome(n) {
|
|
91
|
+
pruneRecent()
|
|
92
|
+
return recent.get(Number(n)) || null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pendingCount() {
|
|
96
|
+
return pending.size
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function solePending() {
|
|
100
|
+
if (pending.size !== 1) return null
|
|
101
|
+
return pending.values().next().value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function dispose() {
|
|
105
|
+
for (const ticket of [...pending.values()]) {
|
|
106
|
+
ticket.resolve({ source: 'dispose', outcome: 'cancelled' })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
allocate,
|
|
112
|
+
get,
|
|
113
|
+
listPending,
|
|
114
|
+
answer,
|
|
115
|
+
discard,
|
|
116
|
+
recentOutcome,
|
|
117
|
+
pendingCount,
|
|
118
|
+
solePending,
|
|
119
|
+
dispose,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 未绑定 chatId 时,私人 bot 把这些回复当成「把本聊天设为审批目标」。 */
|
|
124
|
+
export function isBindConfirmText(text) {
|
|
125
|
+
return /^(是|确认|用作审批|yes|ok)$/i.test(String(text || '').trim())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 只认批准/拒绝。没有永久拒绝。多条 pending 必须带短号。
|
|
130
|
+
* @returns {{ kind: string, n?: number, text?: string } | null}
|
|
131
|
+
*/
|
|
132
|
+
export function parseApprovalReply(text, pendingCount) {
|
|
133
|
+
const raw = String(text || '').trim()
|
|
134
|
+
if (!raw) return null
|
|
135
|
+
const s = raw.replace(/^[@@]\S+\s+/, '').trim()
|
|
136
|
+
|
|
137
|
+
const allowRe = /^(?:批准|同意|允许|yes|ok|allow)\s*#?\s*(\d{1,2})$/i
|
|
138
|
+
const allowRe2 = /^#?\s*(\d{1,2})\s*(?:批准|同意|允许|yes|ok|allow)$/i
|
|
139
|
+
const rejectRe = /^(?:拒绝|否决|no|reject|deny)\s*#?\s*(\d{1,2})$/i
|
|
140
|
+
const rejectRe2 = /^#?\s*(\d{1,2})\s*(?:拒绝|否决|no|reject|deny)$/i
|
|
141
|
+
|
|
142
|
+
let m = s.match(allowRe) || s.match(allowRe2)
|
|
143
|
+
if (m) return { kind: 'allow', n: Number(m[1]) }
|
|
144
|
+
|
|
145
|
+
m = s.match(rejectRe) || s.match(rejectRe2)
|
|
146
|
+
if (m) return { kind: 'reject', n: Number(m[1]) }
|
|
147
|
+
|
|
148
|
+
const bareAllow = /^(?:批准|同意|允许|yes|ok|allow)$/i
|
|
149
|
+
const bareReject = /^(?:拒绝|否决|no|reject|deny)$/i
|
|
150
|
+
if (bareAllow.test(s)) {
|
|
151
|
+
if (pendingCount === 1) return { kind: 'allow-bare' }
|
|
152
|
+
return { kind: 'need-number' }
|
|
153
|
+
}
|
|
154
|
+
if (bareReject.test(s)) {
|
|
155
|
+
if (pendingCount === 1) return { kind: 'reject-bare' }
|
|
156
|
+
return { kind: 'need-number' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatPendingList(list) {
|
|
163
|
+
if (!list || list.length === 0) return '当前没有待处理审批'
|
|
164
|
+
return '当前:' + list.map((t) => '#' + t.n).join('、')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function truncateText(text, max = 1600) {
|
|
168
|
+
const s = String(text || '')
|
|
169
|
+
if (s.length <= max) return s
|
|
170
|
+
return s.slice(0, max - 1) + '…'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function formatOutcomeLabel(outcome) {
|
|
174
|
+
if (outcome === 'allowed-once') return '批准'
|
|
175
|
+
if (outcome === 'rejected') return '拒绝'
|
|
176
|
+
if (outcome === 'cancelled') return '取消'
|
|
177
|
+
if (outcome === 'unavailable') return '未应答'
|
|
178
|
+
return String(outcome || '已处理')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* QQ 推送正文。timeoutSecs 只是「本聊天未答会提醒」,不是审批截止。
|
|
183
|
+
* 有 command 时不重复 escalate 理由,避免把沙箱升级套话当成命令。
|
|
184
|
+
*/
|
|
185
|
+
export function formatApprovalPush(ticket, timeoutSecs) {
|
|
186
|
+
const sid = String(ticket.sessionId || '')
|
|
187
|
+
const tail = sid.length > 8 ? sid.slice(-8) : sid
|
|
188
|
+
const cwd = String(ticket.cwd || '').replace(/[\\/]+$/, '')
|
|
189
|
+
const base = cwd.split(/[\\/]/).pop() || cwd || '(cwd)'
|
|
190
|
+
const args = ticket.args || {}
|
|
191
|
+
const command = args.command ? truncateText(args.command, 1200) : ''
|
|
192
|
+
const filePath = args.file_path || args.path || ''
|
|
193
|
+
const content = args.content ? truncateText(args.content, 400) : ''
|
|
194
|
+
const oldS = args.old_string ? truncateText(args.old_string, 300) : ''
|
|
195
|
+
const newS = args.new_string ? truncateText(args.new_string, 300) : ''
|
|
196
|
+
const extras = [
|
|
197
|
+
args.code ? `代码:${truncateText(args.code, 400)}` : null,
|
|
198
|
+
args.url ? `URL:${truncateText(args.url, 300)}` : null,
|
|
199
|
+
args.query ? `查询:${truncateText(args.query, 300)}` : null,
|
|
200
|
+
args.script ? `脚本:${truncateText(args.script, 400)}` : null,
|
|
201
|
+
args.sql ? `SQL:${truncateText(args.sql, 300)}` : null,
|
|
202
|
+
args.prompt ? `提示词:${truncateText(args.prompt, 300)}` : null,
|
|
203
|
+
args.input ? `输入:${truncateText(args.input, 300)}` : null,
|
|
204
|
+
args.text ? `文本:${truncateText(args.text, 300)}` : null,
|
|
205
|
+
args.body ? `正文:${truncateText(args.body, 300)}` : null,
|
|
206
|
+
args.message ? `消息:${truncateText(args.message, 200)}` : null,
|
|
207
|
+
].filter(Boolean)
|
|
208
|
+
const clipped = Boolean(
|
|
209
|
+
(args.command && command !== args.command)
|
|
210
|
+
|| (args.content && content !== args.content)
|
|
211
|
+
|| (args.old_string && oldS !== args.old_string)
|
|
212
|
+
|| (args.new_string && newS !== args.new_string)
|
|
213
|
+
|| (args.code && extras.some((l) => l.startsWith('代码:') && l.endsWith('…'))),
|
|
214
|
+
)
|
|
215
|
+
const hasCard = Boolean(command || filePath || content || oldS || newS || extras.length)
|
|
216
|
+
const secs = Number(timeoutSecs) > 0 ? Number(timeoutSecs) : 120
|
|
217
|
+
const body = [
|
|
218
|
+
`⚠️ 审批 #${ticket.n}(本聊天 ${secs}s 内未答会提醒)`,
|
|
219
|
+
`会话:${base} · ${tail}`,
|
|
220
|
+
`工具:${ticket.toolName || 'unknown'}`,
|
|
221
|
+
command ? `命令:${command}` : null,
|
|
222
|
+
filePath ? `路径:${truncateText(filePath, 400)}` : null,
|
|
223
|
+
args.description ? `描述:${truncateText(args.description, 200)}` : null,
|
|
224
|
+
content ? `写入:${content}` : null,
|
|
225
|
+
oldS ? `原文:${oldS}` : null,
|
|
226
|
+
newS ? `改成:${newS}` : null,
|
|
227
|
+
...extras,
|
|
228
|
+
ticket.judgeReason ? `判定:${truncateText(ticket.judgeReason, 200)}` : null,
|
|
229
|
+
!command && ticket.reason ? `原因:${truncateText(ticket.justification || ticket.reason, 800)}` : null,
|
|
230
|
+
!hasCard ? '未捕获命令/路径/内容,请在网页查看全文再批准' : null,
|
|
231
|
+
clipped ? '正文已截断,请在网页查看全文再批准' : null,
|
|
232
|
+
`点下方按钮,或回复:批准 ${ticket.n} / 拒绝 ${ticket.n}`,
|
|
233
|
+
].filter(Boolean).join('\n')
|
|
234
|
+
const out = truncateText(body, 2000)
|
|
235
|
+
if (out !== body && !clipped) {
|
|
236
|
+
return truncateText(body.replace(
|
|
237
|
+
`点下方按钮,或回复:批准 ${ticket.n} / 拒绝 ${ticket.n}`,
|
|
238
|
+
`正文已截断,请在网页查看全文再批准\n点下方按钮,或回复:批准 ${ticket.n} / 拒绝 ${ticket.n}`,
|
|
239
|
+
), 2000)
|
|
240
|
+
}
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* QQ 自定义按钮(单聊/群聊,2026-04-23 起无需模板)。
|
|
246
|
+
* 必须挂在 markdown 消息上发出,纯文本 200 也会丢掉 keyboard。
|
|
247
|
+
* @param {number} n
|
|
248
|
+
* @param {string} [userId] 群聊时限制可点的人
|
|
249
|
+
*/
|
|
250
|
+
export function formatApprovalKeyboard(n, userId) {
|
|
251
|
+
const num = String(n)
|
|
252
|
+
const permission = userId
|
|
253
|
+
? { type: 0, specify_user_ids: [String(userId)] }
|
|
254
|
+
: { type: 2 }
|
|
255
|
+
const button = function (id, label, data, style) {
|
|
256
|
+
return {
|
|
257
|
+
id,
|
|
258
|
+
render_data: { label, visited_label: label, style },
|
|
259
|
+
action: {
|
|
260
|
+
type: 1,
|
|
261
|
+
permission,
|
|
262
|
+
data,
|
|
263
|
+
unsupport_tips: '请回复:' + data,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
rows: [
|
|
269
|
+
{
|
|
270
|
+
buttons: [
|
|
271
|
+
button('allow-' + num, '批准', '批准 ' + num, 1),
|
|
272
|
+
button('reject-' + num, '拒绝', '拒绝 ' + num, 0),
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径、JSON、审计、事件日志。无副作用:调用方传入目录。
|
|
3
|
+
*
|
|
4
|
+
* 规则在 ~/.dsh/auto-approve/;凭据/插件配置在 ~/.dsh/approval-bridge/(旧目录名,不要挪)。
|
|
5
|
+
* tryLoadJson 区分缺失与损坏:损坏时调用方不得用默认值覆写磁盘。
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export const NAME = 'dsh-auto-approve'
|
|
12
|
+
|
|
13
|
+
export function dshHome() {
|
|
14
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** auto-approve = 规则/审计;approval-bridge = QQ 凭据与插件配置。 */
|
|
18
|
+
export function pathsFor(home = dshHome()) {
|
|
19
|
+
const auto = join(home, 'auto-approve')
|
|
20
|
+
const bridge = join(home, 'approval-bridge')
|
|
21
|
+
return {
|
|
22
|
+
home,
|
|
23
|
+
auto,
|
|
24
|
+
bridge,
|
|
25
|
+
allowlist: join(auto, 'allowlist.json'),
|
|
26
|
+
audit: join(auto, 'audit.log'),
|
|
27
|
+
events: join(auto, 'events.jsonl'),
|
|
28
|
+
pluginConfig: join(bridge, 'config.json'),
|
|
29
|
+
qqbot: join(bridge, 'qqbot.json'),
|
|
30
|
+
profilePatch: join(home, 'profiles', 'web', 'cordis.patch.yml'),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ensureDir(dir) {
|
|
35
|
+
try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function tryLoadJson(path) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(path)) return { ok: true, missing: true, value: null }
|
|
41
|
+
return { ok: true, missing: false, value: JSON.parse(readFileSync(path, 'utf8')) }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { ok: false, missing: false, error, value: null }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadJson(path, fallback) {
|
|
48
|
+
const loaded = tryLoadJson(path)
|
|
49
|
+
if (!loaded.ok) {
|
|
50
|
+
console.error(`[${NAME}] 读取 ${path} 失败,用默认值`, loaded.error)
|
|
51
|
+
return fallback
|
|
52
|
+
}
|
|
53
|
+
if (loaded.missing) return fallback
|
|
54
|
+
return loaded.value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function chmodPrivate(path) {
|
|
58
|
+
try { chmodSync(path, 0o600) } catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeAtomic(path, text, mode) {
|
|
62
|
+
const tmp = path + '.tmp'
|
|
63
|
+
try {
|
|
64
|
+
ensureDir(dirname(path))
|
|
65
|
+
if (mode != null) writeFileSync(tmp, text, { encoding: 'utf8', mode })
|
|
66
|
+
else writeFileSync(tmp, text, 'utf8')
|
|
67
|
+
renameSync(tmp, path)
|
|
68
|
+
chmodPrivate(path)
|
|
69
|
+
return true
|
|
70
|
+
} catch (error) {
|
|
71
|
+
try { unlinkSync(tmp) } catch { /* ignore */ }
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function saveJson(path, data) {
|
|
77
|
+
try {
|
|
78
|
+
writeAtomic(path, JSON.stringify(data, null, 2) + '\n')
|
|
79
|
+
return true
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`[${NAME}] 写入 ${path} 失败`, error)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 凭据文件:0600。目录也尽量收紧。 */
|
|
87
|
+
export function saveSecretJson(path, data) {
|
|
88
|
+
try {
|
|
89
|
+
writeAtomic(path, JSON.stringify(data, null, 2) + '\n', 0o600)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`[${NAME}] 写入凭据 ${path} 失败`, error)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function appendLine(path, line) {
|
|
97
|
+
try {
|
|
98
|
+
ensureDir(dirname(path))
|
|
99
|
+
appendFileSync(path, line, 'utf8')
|
|
100
|
+
chmodPrivate(path)
|
|
101
|
+
} catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function audit(auditPath, line) {
|
|
105
|
+
appendLine(auditPath, `[${new Date().toISOString()}] ${line}\n`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @type {{ path: string, mtimeMs: number, size: number, records: object[] } | null} */
|
|
109
|
+
let eventsCache = null
|
|
110
|
+
|
|
111
|
+
function loadEventRecords(eventsPath) {
|
|
112
|
+
try {
|
|
113
|
+
const st = statSync(eventsPath)
|
|
114
|
+
if (
|
|
115
|
+
eventsCache
|
|
116
|
+
&& eventsCache.path === eventsPath
|
|
117
|
+
&& eventsCache.mtimeMs === st.mtimeMs
|
|
118
|
+
&& eventsCache.size === st.size
|
|
119
|
+
) {
|
|
120
|
+
return eventsCache.records
|
|
121
|
+
}
|
|
122
|
+
const text = readFileSync(eventsPath, 'utf8')
|
|
123
|
+
const records = []
|
|
124
|
+
for (const line of text.split('\n')) {
|
|
125
|
+
if (!line.trim()) continue
|
|
126
|
+
try {
|
|
127
|
+
const ev = JSON.parse(line)
|
|
128
|
+
if (Number.isInteger(ev.id)) records.push(ev)
|
|
129
|
+
} catch { /* skip */ }
|
|
130
|
+
}
|
|
131
|
+
eventsCache = { path: eventsPath, mtimeMs: st.mtimeMs, size: st.size, records }
|
|
132
|
+
return records
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function readEventsSince(eventsPath, sessionId, since) {
|
|
139
|
+
const records = loadEventRecords(eventsPath)
|
|
140
|
+
if (!records) return []
|
|
141
|
+
const events = []
|
|
142
|
+
for (const ev of records) {
|
|
143
|
+
if (ev.id <= since) continue
|
|
144
|
+
if (sessionId && ev.sessionId !== sessionId) continue
|
|
145
|
+
events.push(ev)
|
|
146
|
+
}
|
|
147
|
+
return events
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const EVENTS_MAX_BYTES = 2 * 1024 * 1024
|
|
151
|
+
export const EVENTS_KEEP = 2000
|
|
152
|
+
|
|
153
|
+
export function trimEventsFile(eventsPath, maxBytes = EVENTS_MAX_BYTES, keep = EVENTS_KEEP) {
|
|
154
|
+
try {
|
|
155
|
+
const st = statSync(eventsPath)
|
|
156
|
+
if (st.size < maxBytes) return false
|
|
157
|
+
const records = loadEventRecords(eventsPath) || []
|
|
158
|
+
const kept = records.slice(-Math.max(1, keep))
|
|
159
|
+
const text = kept.length ? kept.map((r) => JSON.stringify(r)).join('\n') + '\n' : ''
|
|
160
|
+
writeAtomic(eventsPath, text)
|
|
161
|
+
eventsCache = null
|
|
162
|
+
return true
|
|
163
|
+
} catch {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function appendEvent(eventsPath, ev) {
|
|
169
|
+
ensureDir(dirname(eventsPath))
|
|
170
|
+
appendFileSync(eventsPath, JSON.stringify(ev) + '\n', 'utf8')
|
|
171
|
+
chmodPrivate(eventsPath)
|
|
172
|
+
eventsCache = null
|
|
173
|
+
trimEventsFile(eventsPath)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function maxEventId(eventsPath) {
|
|
177
|
+
let max = 0
|
|
178
|
+
try {
|
|
179
|
+
const text = readFileSync(eventsPath, 'utf8')
|
|
180
|
+
for (const line of text.split('\n')) {
|
|
181
|
+
if (!line.trim()) continue
|
|
182
|
+
try {
|
|
183
|
+
const ev = JSON.parse(line)
|
|
184
|
+
if (Number.isInteger(ev.id) && ev.id > max) max = ev.id
|
|
185
|
+
} catch { /* skip */ }
|
|
186
|
+
}
|
|
187
|
+
} catch { /* missing */ }
|
|
188
|
+
return max
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function maskSecret(value) {
|
|
192
|
+
const s = String(value || '')
|
|
193
|
+
if (!s) return ''
|
|
194
|
+
if (s.length <= 4) return '****'
|
|
195
|
+
return s.slice(0, 2) + '****' + s.slice(-2)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 网页审批框用的独立 signal:父 signal 中止时跟着中止;
|
|
200
|
+
* 主动 abort() 只关网页,不碰父 signal(避免整单变成 cancelled)。
|
|
201
|
+
* @param {AbortSignal | undefined} parent
|
|
202
|
+
*/
|
|
203
|
+
export function forkAbortSignal(parent) {
|
|
204
|
+
const child = new AbortController()
|
|
205
|
+
const forward = () => {
|
|
206
|
+
if (child.signal.aborted) return
|
|
207
|
+
try { child.abort(parent && parent.reason) } catch { /* ignore */ }
|
|
208
|
+
}
|
|
209
|
+
if (parent) {
|
|
210
|
+
if (parent.aborted) forward()
|
|
211
|
+
else parent.addEventListener('abort', forward, { once: true })
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
signal: child.signal,
|
|
215
|
+
abort() {
|
|
216
|
+
if (parent) {
|
|
217
|
+
try { parent.removeEventListener('abort', forward) } catch { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
forward()
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 把 req.signal 换成网页专用 signal;失败则 false(网页框可能关不掉)。 */
|
|
225
|
+
export function replaceRequestSignal(req, signal) {
|
|
226
|
+
if (!req || typeof req !== 'object') return false
|
|
227
|
+
try {
|
|
228
|
+
req.signal = signal
|
|
229
|
+
if (req.signal === signal) return true
|
|
230
|
+
} catch { /* readonly */ }
|
|
231
|
+
try {
|
|
232
|
+
Object.defineProperty(req, 'signal', {
|
|
233
|
+
configurable: true,
|
|
234
|
+
enumerable: true,
|
|
235
|
+
writable: true,
|
|
236
|
+
value: signal,
|
|
237
|
+
})
|
|
238
|
+
return req.signal === signal
|
|
239
|
+
} catch {
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
}
|