@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.
@@ -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
+ }