@dnalec/dsh-auto-approve 0.1.2 → 0.2.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/src/tickets.mjs DELETED
@@ -1,277 +0,0 @@
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
- }