@mzzsfy/dsh-turn-notify 0.7.2
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/README.md +146 -0
- package/cordis.patch.yml +13 -0
- package/package.json +50 -0
- package/src/client.js +1857 -0
- package/src/core.mjs +583 -0
- package/src/index.js +660 -0
- package/test/client-id.test.mjs +16 -0
- package/test/core.test.mjs +736 -0
- package/test/event-wiring.test.mjs +585 -0
- package/test/flow.test.mjs +563 -0
- package/test/hmr-heal.test.mjs +31 -0
- package/test/im-targets.test.mjs +76 -0
- package/test/parity.test.mjs +312 -0
- package/test/rename.route.test.mjs +235 -0
- package/test/route.test.mjs +377 -0
- package/test/sounds.route.test.mjs +257 -0
- package/test/switch-guard.test.mjs +32 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// 纯逻辑层:分类映射 / 过滤决策 / 投影 / 认领状态机 / webhook 组装 / 音效映射 / 上传校验。
|
|
2
|
+
// 无 npm 依赖,host 与 client 均可复用;readRawBody 与 sendWebhook 为 host 专属 I/O 辅助,
|
|
3
|
+
// 依赖经参数注入保持可测。时间与随机经参数注入。
|
|
4
|
+
|
|
5
|
+
export const CATEGORY_DONE = 'completed'
|
|
6
|
+
export const CATEGORY_ERROR = 'error'
|
|
7
|
+
export const CATEGORY_INTERRUPTED = 'interrupted'
|
|
8
|
+
export const CATEGORY_APPROVAL = 'approval'
|
|
9
|
+
export const CATEGORY_ASK = 'ask'
|
|
10
|
+
export const CATEGORY_MAX_TOKENS = 'max-tokens'
|
|
11
|
+
|
|
12
|
+
export const CATEGORIES = [CATEGORY_DONE, CATEGORY_ERROR, CATEGORY_INTERRUPTED, CATEGORY_APPROVAL, CATEGORY_ASK, CATEGORY_MAX_TOKENS]
|
|
13
|
+
|
|
14
|
+
export const CATEGORY_LABELS = {
|
|
15
|
+
[CATEGORY_DONE]: '任务完成',
|
|
16
|
+
[CATEGORY_ERROR]: '任务出错',
|
|
17
|
+
[CATEGORY_INTERRUPTED]: '被中断',
|
|
18
|
+
[CATEGORY_APPROVAL]: '等待审批',
|
|
19
|
+
[CATEGORY_ASK]: 'AI 提问',
|
|
20
|
+
[CATEGORY_MAX_TOKENS]: '达到上限',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 内置合成音名(参数见 client 音色表),按分类给默认映射。
|
|
24
|
+
export const TONE_UP_ARPEGGIO = 'up-arpeggio'
|
|
25
|
+
export const TONE_BELL = 'bell'
|
|
26
|
+
export const TONE_DUO = 'duo'
|
|
27
|
+
export const TONE_ALARM_SQUARE = 'alarm-square'
|
|
28
|
+
export const TONE_LOW_HUM = 'low-hum'
|
|
29
|
+
export const TONE_DOUBLE_PING = 'double-ping'
|
|
30
|
+
export const TONE_TICK = 'tick'
|
|
31
|
+
export const TONE_DOWN_SLIDE = 'down-slide'
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_TONES = {
|
|
34
|
+
[CATEGORY_DONE]: TONE_UP_ARPEGGIO,
|
|
35
|
+
[CATEGORY_ERROR]: TONE_ALARM_SQUARE,
|
|
36
|
+
[CATEGORY_INTERRUPTED]: TONE_ALARM_SQUARE,
|
|
37
|
+
[CATEGORY_APPROVAL]: TONE_DOUBLE_PING,
|
|
38
|
+
[CATEGORY_ASK]: TONE_DOUBLE_PING,
|
|
39
|
+
[CATEGORY_MAX_TOKENS]: TONE_DOWN_SLIDE,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 同类可换的内置备选。
|
|
43
|
+
export const BUILTIN_TONES = {
|
|
44
|
+
[TONE_UP_ARPEGGIO]: '上行琶音',
|
|
45
|
+
[TONE_BELL]: '铃铛',
|
|
46
|
+
[TONE_DUO]: '清脆双音',
|
|
47
|
+
[TONE_ALARM_SQUARE]: '警报方波',
|
|
48
|
+
[TONE_LOW_HUM]: '低鸣',
|
|
49
|
+
[TONE_DOUBLE_PING]: '双音提示',
|
|
50
|
+
[TONE_TICK]: '嘀嗒',
|
|
51
|
+
[TONE_DOWN_SLIDE]: '低音下滑',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const AUDIO_EXTS = ['wav', 'mp3', 'ogg']
|
|
55
|
+
|
|
56
|
+
// 扩展名到 MIME 的一比一映射:host 响应头使用,client 侧为镜像,由 parity 测试锁定
|
|
57
|
+
export const MIME_BY_EXT = { wav: 'audio/wav', ogg: 'audio/ogg', mp3: 'audio/mpeg' }
|
|
58
|
+
|
|
59
|
+
export const mimeOf = (ext) => MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
|
60
|
+
|
|
61
|
+
// 未显式设置音量时的默认值。
|
|
62
|
+
export const DEFAULT_VOLUME = 0.6
|
|
63
|
+
|
|
64
|
+
// 音量解析:未设置或非法回落默认,显式零(静音)保留。
|
|
65
|
+
export function parseVolume(raw) {
|
|
66
|
+
if (raw === null || raw === undefined) return DEFAULT_VOLUME
|
|
67
|
+
const value = Number(raw)
|
|
68
|
+
return value >= 0 && value <= 1 ? value : DEFAULT_VOLUME
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const PROJECTION_CAPACITY = 20
|
|
72
|
+
export const PROJECTION_TTL_MS = 60 * 1000
|
|
73
|
+
export const CLAIM_LOCK_TTL_MS = 30 * 1000
|
|
74
|
+
export const WEBHOOK_TIMEOUT_MS = 10 * 1000
|
|
75
|
+
export const MIN_TURN_DURATION_MS = 5 * 1000
|
|
76
|
+
export const UPLOAD_FILE_MAX_BYTES = 2 * 1024 * 1024
|
|
77
|
+
export const UPLOAD_TOTAL_MAX_BYTES = 10 * 1024 * 1024
|
|
78
|
+
export const TITLE_MAX_CHARS = 60
|
|
79
|
+
|
|
80
|
+
// IM 投递目标上限:settings 体积与面板列表的防护栏
|
|
81
|
+
const IM_TARGETS_MAX = 16
|
|
82
|
+
|
|
83
|
+
// ID 规格与 dsh-im delivery-service 的 BOT_ID/TARGET_ID 一致,写侧拦截防落库后投递静默失败
|
|
84
|
+
const IM_BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
85
|
+
const IM_TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/
|
|
86
|
+
|
|
87
|
+
export const isValidImBotId = (value) => typeof value === 'string' && IM_BOT_ID_PATTERN.test(value)
|
|
88
|
+
|
|
89
|
+
const TURN_END_KIND = 'turn/end'
|
|
90
|
+
|
|
91
|
+
// turn/end reason.kind 到通知分类的映射;未知 kind(插件可扩展)返回 null。
|
|
92
|
+
const REASON_KIND_TO_CATEGORY = {
|
|
93
|
+
[CATEGORY_DONE]: CATEGORY_DONE,
|
|
94
|
+
[CATEGORY_ERROR]: CATEGORY_ERROR,
|
|
95
|
+
aborted: CATEGORY_ERROR,
|
|
96
|
+
[CATEGORY_INTERRUPTED]: CATEGORY_INTERRUPTED,
|
|
97
|
+
blocked: CATEGORY_APPROVAL,
|
|
98
|
+
[CATEGORY_MAX_TOKENS]: CATEGORY_MAX_TOKENS,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function mapEventToCategory(type, data) {
|
|
102
|
+
if (type === TURN_END_KIND) {
|
|
103
|
+
const kind = data && data.reason && data.reason.kind
|
|
104
|
+
return Object.prototype.hasOwnProperty.call(REASON_KIND_TO_CATEGORY, kind) ? REASON_KIND_TO_CATEGORY[kind] : null
|
|
105
|
+
}
|
|
106
|
+
if (type === 'tool/call' && data && data.name === 'ask_user_question') return CATEGORY_ASK
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// SessionHeader 判定子代理会话:origin 标记优先,辅以委托深度。
|
|
111
|
+
export function isSubagent(header) {
|
|
112
|
+
const source = header || {}
|
|
113
|
+
return source.origin === 'subagent' || (source.delegationDepth ?? 0) > 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 子代理结束到父会话被唤醒的投递裕量;超过视为与子代理无关的新回合。
|
|
117
|
+
export const SUBAGENT_WAKE_WINDOW_MS = 30 * 1000
|
|
118
|
+
|
|
119
|
+
// 唤醒回合判定:父会话回合开始前窗口内,有其子代理会话结束(childDoneAt 为时间戳,null 表示无记录)。
|
|
120
|
+
export function isSubagentWakeTurn({ childDoneAt, turnStartMs, windowMs = SUBAGENT_WAKE_WINDOW_MS }) {
|
|
121
|
+
if (typeof childDoneAt !== 'number') return false
|
|
122
|
+
const elapsed = turnStartMs - childDoneAt
|
|
123
|
+
return elapsed >= 0 && elapsed <= windowMs
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DURATION_FILTERED_KINDS = [TURN_END_KIND]
|
|
127
|
+
|
|
128
|
+
// 通知总决策:分类开关 + 子代理豁免 + 子代理相关静默(唤醒回执 / 等待委托,仅 completed)
|
|
129
|
+
// + 碎轮过滤(仅 turn/end 类)。
|
|
130
|
+
export function shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren }) {
|
|
131
|
+
if (category === null) return false
|
|
132
|
+
if ((settings.enabled || {})[category] === false) return false
|
|
133
|
+
if (settings.rootsOnly !== false && isSubagent(header)) return false
|
|
134
|
+
if ((wakeTurn === true || awaitingChildren === true) && category === CATEGORY_DONE && settings.suppressSubagentWake !== false) return false
|
|
135
|
+
if (DURATION_FILTERED_KINDS.indexOf(kind) >= 0 && durationMs !== null && durationMs < settings.minTurnDurationMs) return false
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildUnit({ id, category, status, sessionTitle, workspace, durationMs, ts }) {
|
|
140
|
+
const label = CATEGORY_LABELS[category] || category
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
category,
|
|
144
|
+
status,
|
|
145
|
+
session: sessionTitle,
|
|
146
|
+
workspace,
|
|
147
|
+
durationMs,
|
|
148
|
+
ts,
|
|
149
|
+
text: '[dsh] ' + label + (sessionTitle ? ': ' + sessionTitle : ''),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 投影单元字段到 webhook 结构化字段的一比一映射(text 随行)。
|
|
154
|
+
export function buildWebhookPayload(unit) {
|
|
155
|
+
return {
|
|
156
|
+
text: unit.text,
|
|
157
|
+
event: unit.id,
|
|
158
|
+
category: unit.category,
|
|
159
|
+
status: unit.status,
|
|
160
|
+
session: unit.session,
|
|
161
|
+
workspace: unit.workspace,
|
|
162
|
+
durationMs: unit.durationMs,
|
|
163
|
+
ts: unit.ts,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 通知投影:环形容量 + 读取时过期清理;now 注入便于测试。
|
|
168
|
+
// version 单调递增支撑长轮询:push 与 bump 各递增一次并唤醒全部等待者,
|
|
169
|
+
// wait 在无更新版本时挂起至唤醒或超时,超时与 dispose 均以当前版本收尾不报错。
|
|
170
|
+
export function createProjection({ capacity = PROJECTION_CAPACITY, ttlMs = PROJECTION_TTL_MS, now = Date.now }) {
|
|
171
|
+
const ring = []
|
|
172
|
+
let version = 0
|
|
173
|
+
const waiters = new Set()
|
|
174
|
+
const settle = (waiter) => {
|
|
175
|
+
clearTimeout(waiter.timer)
|
|
176
|
+
waiters.delete(waiter)
|
|
177
|
+
waiter.resolve(version)
|
|
178
|
+
}
|
|
179
|
+
const wakeAll = () => { for (const waiter of [...waiters]) settle(waiter) }
|
|
180
|
+
return {
|
|
181
|
+
push(unit) {
|
|
182
|
+
const current = now()
|
|
183
|
+
while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
|
|
184
|
+
ring.push(unit)
|
|
185
|
+
while (ring.length > capacity) ring.shift()
|
|
186
|
+
version += 1
|
|
187
|
+
wakeAll()
|
|
188
|
+
},
|
|
189
|
+
bump() {
|
|
190
|
+
version += 1
|
|
191
|
+
wakeAll()
|
|
192
|
+
},
|
|
193
|
+
version: () => version,
|
|
194
|
+
wait(cursor, timeoutMs) {
|
|
195
|
+
if (version > cursor) return Promise.resolve(version)
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const waiter = { resolve, timer: null }
|
|
198
|
+
waiter.timer = setTimeout(() => settle(waiter), timeoutMs)
|
|
199
|
+
waiters.add(waiter)
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
dispose: wakeAll,
|
|
203
|
+
list() {
|
|
204
|
+
const current = now()
|
|
205
|
+
while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
|
|
206
|
+
return ring.slice()
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 认领决策(读阶段):done 标记终态只补已读;有效他锁跳过;过期锁接管;无锁认领。
|
|
212
|
+
// 值非法视为无锁接管;锁归属自己视为继续(重试补完成标记)。
|
|
213
|
+
export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOCK_TTL_MS }) {
|
|
214
|
+
if (done !== null && done !== undefined) return 'done'
|
|
215
|
+
if (stored === null || stored === undefined) return 'claim'
|
|
216
|
+
let lock = null
|
|
217
|
+
try {
|
|
218
|
+
lock = JSON.parse(stored)
|
|
219
|
+
} catch {
|
|
220
|
+
lock = null
|
|
221
|
+
}
|
|
222
|
+
if (lock === null || typeof lock !== 'object' || typeof lock.at !== 'number' || typeof lock.wid !== 'string') return 'takeover'
|
|
223
|
+
if (now - lock.at >= lockTtlMs) return 'takeover'
|
|
224
|
+
return lock.wid === windowId ? 'claim' : 'skip'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 用户行动空闲满此时长视为离开:聚焦静默不再适用,通知全通道齐发。
|
|
228
|
+
export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
|
|
229
|
+
|
|
230
|
+
// 发声通道判定:页内提示、提示音与系统弹窗各自独立开关,聚焦静默仅压声音与系统弹窗;
|
|
231
|
+
// 提示音另受分类配置约束:soundCategories 中该分类显式 false 即静音,缺省键与空分类放行。
|
|
232
|
+
// 系统弹窗须授权,想弹而未授权时降级标题闪烁(调用方再按降级提示开关呈现)。
|
|
233
|
+
// idleMs 为距上次用户行动的时长,满阈值视为离开,等效未聚焦。
|
|
234
|
+
// pageSound 为页内提示音:页内提示弹出且通知声音未播时补一声(聚焦场景的听觉提醒),
|
|
235
|
+
// 不受聚焦静默压制,受页内总开关、页内分类配置(pageSoundCategories,独立于提示音分类)
|
|
236
|
+
// 与通知声音互斥约束(同一通知至多一声)。
|
|
237
|
+
export function chooseChannels({ hasFocus, permission, focusQuiet = true, toastEnabled = true, soundEnabled = true, soundCategories = null, category = null, systemEnabled = true, idleMs = null, idleThresholdMs = USER_IDLE_AWAY_MS, pageSoundEnabled = false, pageSoundCategories = null }) {
|
|
238
|
+
const idleAway = typeof idleMs === 'number' && idleMs >= idleThresholdMs
|
|
239
|
+
const quiet = hasFocus && focusQuiet && !idleAway
|
|
240
|
+
const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
|
|
241
|
+
const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
|
|
242
|
+
const sound = !quiet && soundEnabled && !categoryMuted
|
|
243
|
+
return {
|
|
244
|
+
toast: toastEnabled,
|
|
245
|
+
sound,
|
|
246
|
+
system: !quiet && systemEnabled && permission === 'granted',
|
|
247
|
+
blink: !quiet && systemEnabled && permission !== 'granted',
|
|
248
|
+
pageSound: pageSoundEnabled && toastEnabled && !pageCategoryMuted && !sound,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 分类音效解析:映射指向已上传音效则用自定义;指向内置音名则用该内置;否则(未配置/已失效)回落内置默认。
|
|
253
|
+
export function resolveSound({ category, mapping, uploadedIds }) {
|
|
254
|
+
const wanted = (mapping || {})[category]
|
|
255
|
+
if (typeof wanted === 'string' && wanted.length > 0) {
|
|
256
|
+
if (uploadedIds.indexOf(wanted) >= 0) return { kind: 'custom', id: wanted }
|
|
257
|
+
if (Object.prototype.hasOwnProperty.call(BUILTIN_TONES, wanted)) return { kind: 'builtin', name: wanted }
|
|
258
|
+
}
|
|
259
|
+
return { kind: 'builtin', name: DEFAULT_TONES[category] }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 映射双作用域合并:全局为底,本地键覆盖(空串=显式内置默认,同样覆盖);
|
|
263
|
+
// 两参均容错空值,返回新对象不改入参。
|
|
264
|
+
export function mergeMapping(globalMapping, localMapping) {
|
|
265
|
+
const merged = {}
|
|
266
|
+
for (const key of Object.keys(globalMapping || {})) merged[key] = globalMapping[key]
|
|
267
|
+
for (const key of Object.keys(localMapping || {})) merged[key] = localMapping[key]
|
|
268
|
+
return merged
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 死链识别:映射值既非内置音名也非已上传 id 即失效引用,去重并按首次出现排序,
|
|
272
|
+
// 供面板呈现死链与发声回退归因。
|
|
273
|
+
export function deadCustomIds(mapping, uploadedIds) {
|
|
274
|
+
const uploaded = uploadedIds || []
|
|
275
|
+
const dead = []
|
|
276
|
+
for (const value of Object.values(mapping || {})) {
|
|
277
|
+
if (typeof value !== 'string' || value.length === 0) continue
|
|
278
|
+
if (Object.prototype.hasOwnProperty.call(BUILTIN_TONES, value)) continue
|
|
279
|
+
if (uploaded.indexOf(value) >= 0) continue
|
|
280
|
+
if (dead.indexOf(value) < 0) dead.push(value)
|
|
281
|
+
}
|
|
282
|
+
return dead
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 音效展示名长度上限:超长截断提示不佳,直接拒绝。
|
|
286
|
+
export const SOUND_NAME_MAX_CHARS = 64
|
|
287
|
+
|
|
288
|
+
// 展示名黑名单:分隔符与点防注入索引键或文件名形态,控制字符与 Windows 非法字符防后续落盘复用失败。
|
|
289
|
+
const SOUND_NAME_FORBIDDEN = /[\\/:*?"<>|.\u0000-\u001f]/
|
|
290
|
+
|
|
291
|
+
// 展示名校验:trim 归一化随结果返回;名称仅存展示名索引,不进文件名;
|
|
292
|
+
// 与内置音色重名会污染映射 id 命名空间,一并拒绝。
|
|
293
|
+
export function validateSoundName(rawName) {
|
|
294
|
+
const name = String(rawName ?? '').trim()
|
|
295
|
+
if (name.length === 0) return { ok: false, reason: '名称不能为空' }
|
|
296
|
+
if (name.length > SOUND_NAME_MAX_CHARS) return { ok: false, reason: '名称超过长度上限' }
|
|
297
|
+
if (SOUND_NAME_FORBIDDEN.test(name)) return { ok: false, reason: '名称含非法字符' }
|
|
298
|
+
if (Object.prototype.hasOwnProperty.call(BUILTIN_TONES, name)) return { ok: false, reason: '名称与内置音色冲突' }
|
|
299
|
+
return { ok: true, name }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function uploadExt(filename) {
|
|
303
|
+
const match = /\.([^.]+)$/.exec(String(filename || ''))
|
|
304
|
+
return match ? match[1].toLowerCase() : ''
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 上传校验:扩展名白名单 + 单文件上限 + 总量上限。
|
|
308
|
+
export function validateUpload({ filename, size, totalBytes }) {
|
|
309
|
+
const ext = uploadExt(filename)
|
|
310
|
+
if (AUDIO_EXTS.indexOf(ext) < 0) return { ok: false, reason: '仅支持 ' + AUDIO_EXTS.join(' / ') }
|
|
311
|
+
if (!(size >= 1) || size > UPLOAD_FILE_MAX_BYTES) return { ok: false, reason: '单文件超过上限' }
|
|
312
|
+
if (totalBytes + size > UPLOAD_TOTAL_MAX_BYTES) return { ok: false, reason: '音效总量超过上限' }
|
|
313
|
+
return { ok: true, ext }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 回合起始记录滞留上限:会话异常终止不发 turn/end 时按超时回收
|
|
317
|
+
export const OPEN_TURN_STALE_MS = 60 * 60 * 1000
|
|
318
|
+
|
|
319
|
+
// 未封账会话的事件保留上限:标题只依赖首个文本块,超限截尾防无文本消息无限累积
|
|
320
|
+
export const SESSION_EVENTS_MAX = 8
|
|
321
|
+
|
|
322
|
+
// 惰性过期:删除时间戳早于 maxAgeMs 的条目,防异常时序下按会话容器无界滞留。
|
|
323
|
+
export function pruneTimestamps(map, now, maxAgeMs) {
|
|
324
|
+
for (const [key, at] of map) {
|
|
325
|
+
if (now - at > maxAgeMs) map.delete(key)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 从会话事件流提取首个用户文本作为标题,超长按码点截断防切断代理对;无用户文本返回 null。
|
|
330
|
+
export function sessionTitle(events) {
|
|
331
|
+
for (const event of events) {
|
|
332
|
+
if (event.type !== 'user/message') continue
|
|
333
|
+
const blocks = event.data && Array.isArray(event.data.content) ? event.data.content : []
|
|
334
|
+
for (const block of blocks) {
|
|
335
|
+
if (block && typeof block.text === 'string' && block.text.trim().length > 0) {
|
|
336
|
+
const text = block.text.trim()
|
|
337
|
+
if (text.length <= TITLE_MAX_CHARS) return text
|
|
338
|
+
// 先按码元粗截再按码点收尾,避免对超长文本全量分配码点数组
|
|
339
|
+
const head = text.slice(0, TITLE_MAX_CHARS + 1)
|
|
340
|
+
return Array.from(head).slice(0, TITLE_MAX_CHARS).join('')
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 会话事件有界累积:仅 user/message 入库;标题一旦可提取即封账,封账后 store 中
|
|
348
|
+
// 以标题字符串替代事件数组,未封账会话超上限截尾,store 与 titled 原位更新。
|
|
349
|
+
export function collectSessionEvents(store, titled, sessionId, event) {
|
|
350
|
+
if (event.type !== 'user/message') return
|
|
351
|
+
if (titled.has(sessionId)) return
|
|
352
|
+
const events = store.get(sessionId)
|
|
353
|
+
const merged = (Array.isArray(events) ? events : []).concat([event]).slice(-SESSION_EVENTS_MAX)
|
|
354
|
+
const title = sessionTitle(merged)
|
|
355
|
+
if (title === null) store.set(sessionId, merged)
|
|
356
|
+
else {
|
|
357
|
+
titled.add(sessionId)
|
|
358
|
+
store.set(sessionId, title)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 会话标题读取:封账会话为字符串,未封账为事件数组,空缺为 null。
|
|
363
|
+
export function storedSessionTitle(store, sessionId) {
|
|
364
|
+
const value = store.get(sessionId)
|
|
365
|
+
if (typeof value === 'string') return value
|
|
366
|
+
if (Array.isArray(value)) return sessionTitle(value)
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 映射路由音效 id 校验:空串(清除映射)放行,其余须为已上传 id 或内置音名。
|
|
371
|
+
export function validateMappingId(id, uploadedIds) {
|
|
372
|
+
if (typeof id !== 'string' || id.length === 0) return true
|
|
373
|
+
return uploadedIds.indexOf(id) >= 0 || Object.prototype.hasOwnProperty.call(BUILTIN_TONES, id)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 请求体读取:超限先 reject 再断流;'close' 兜底 reject,防 destroy 后悬挂。
|
|
377
|
+
export function readRawBody(req, maxBytes) {
|
|
378
|
+
return new Promise((resolve, reject) => {
|
|
379
|
+
let settled = false
|
|
380
|
+
const fail = (error) => {
|
|
381
|
+
if (settled) return
|
|
382
|
+
settled = true
|
|
383
|
+
reject(error)
|
|
384
|
+
}
|
|
385
|
+
const done = (value) => {
|
|
386
|
+
if (settled) return
|
|
387
|
+
settled = true
|
|
388
|
+
resolve(value)
|
|
389
|
+
}
|
|
390
|
+
let size = 0
|
|
391
|
+
const chunks = []
|
|
392
|
+
req.on('data', (chunk) => {
|
|
393
|
+
size += chunk.length
|
|
394
|
+
if (size > maxBytes) {
|
|
395
|
+
fail(new Error('请求体超过上限'))
|
|
396
|
+
req.destroy()
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
chunks.push(chunk)
|
|
400
|
+
})
|
|
401
|
+
req.on('end', () => done(Buffer.concat(chunks)))
|
|
402
|
+
req.on('error', fail)
|
|
403
|
+
req.on('close', () => {
|
|
404
|
+
if (!req.readableEnded) fail(new Error('请求连接中断'))
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 审批观察器包装:notify 异步投递,next() 同步立即放行,不阻塞 waterfall。
|
|
410
|
+
export function createApprovalTap(notify, schedule) {
|
|
411
|
+
return function approvalTap(_req, next) {
|
|
412
|
+
schedule(() => notify({ category: CATEGORY_APPROVAL }))
|
|
413
|
+
return next()
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// webhook 直发:未配置跳过;任何失败不抛出(fire-and-forget,不重试)。
|
|
418
|
+
// 返回真实投递结果供测试按钮呈现,真实通知路径以 void 忽略。
|
|
419
|
+
export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
|
|
420
|
+
if (typeof url !== 'string' || url.trim().length === 0) return { ok: false, detail: '未配置 webhook' }
|
|
421
|
+
try {
|
|
422
|
+
const response = await fetchImpl(url.trim(), {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: { 'content-type': 'application/json' },
|
|
425
|
+
body: JSON.stringify(payload),
|
|
426
|
+
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
|
427
|
+
})
|
|
428
|
+
return { ok: response.ok, detail: 'HTTP ' + response.status }
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// webhook 不可达不打扰主循环,失败即弃,错误信息仅供测试路径呈现
|
|
431
|
+
return { ok: false, detail: error && error.message ? error.message : String(error) }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const CONFIG_WEBHOOK_SCHEMES = ['http:', 'https:']
|
|
436
|
+
|
|
437
|
+
// imTargets 读侧归一化:非数组回空,剔除形态非法项,仅保留两字段
|
|
438
|
+
export function normalizeImTargets(raw) {
|
|
439
|
+
if (!Array.isArray(raw)) return []
|
|
440
|
+
return raw
|
|
441
|
+
.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
442
|
+
&& typeof item.botId === 'string' && item.botId.length > 0
|
|
443
|
+
&& typeof item.targetId === 'string' && item.targetId.length > 0)
|
|
444
|
+
.map(({ botId, targetId }) => ({ botId, targetId }))
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---- IM 目标列表操作(client LOGIC 段镜像,parity 测试保证不漂移) ----
|
|
448
|
+
|
|
449
|
+
// botId/targetId 字符集均不含 '/',拼接键无歧义
|
|
450
|
+
export const imTargetKeyOf = (item) => item.botId + '/' + item.targetId
|
|
451
|
+
|
|
452
|
+
// 勾选幂等:同一 botId+targetId 只保留一份;勾选追加到尾部,取消即移除
|
|
453
|
+
export function toggleImTargetList(list, botId, targetId, checked) {
|
|
454
|
+
const wanted = { botId, targetId }
|
|
455
|
+
const rest = list.filter((item) => imTargetKeyOf(item) !== imTargetKeyOf(wanted))
|
|
456
|
+
return checked ? rest.concat([wanted]) : rest
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function removeImTargetFromList(list, botId, targetId) {
|
|
460
|
+
return list.filter((item) => imTargetKeyOf(item) !== botId + '/' + targetId)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 取消注册:移除该 bot 全部目标
|
|
464
|
+
export function unregisterImBotList(list, botId) {
|
|
465
|
+
return list.filter((item) => item.botId !== botId)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 已绑 bot:按首次绑定顺序去重
|
|
469
|
+
export function imBoundBotIds(list) {
|
|
470
|
+
const botIds = []
|
|
471
|
+
for (const item of list) {
|
|
472
|
+
if (!botIds.includes(item.botId)) botIds.push(item.botId)
|
|
473
|
+
}
|
|
474
|
+
return botIds
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 配置补丁校验:顶层键白名单,webhookUrl 空串(禁用)或 http(s) URL,
|
|
478
|
+
// 时长须非负整数,开关须布尔,enabled 分类须已知。返回归一化后的补丁。
|
|
479
|
+
export function validateConfigPatch(patch) {
|
|
480
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
481
|
+
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets']
|
|
482
|
+
for (const key of Object.keys(patch)) {
|
|
483
|
+
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
484
|
+
}
|
|
485
|
+
const next = {}
|
|
486
|
+
if ('webhookUrl' in patch) {
|
|
487
|
+
const url = patch.webhookUrl
|
|
488
|
+
if (typeof url !== 'string') return { ok: false, reason: 'webhookUrl 须为字符串' }
|
|
489
|
+
const trimmed = url.trim()
|
|
490
|
+
if (trimmed.length > 0) {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = new URL(trimmed)
|
|
493
|
+
if (CONFIG_WEBHOOK_SCHEMES.indexOf(parsed.protocol) < 0) return { ok: false, reason: 'webhookUrl 仅支持 http(s)' }
|
|
494
|
+
} catch {
|
|
495
|
+
return { ok: false, reason: 'webhookUrl 不是合法 URL' }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
next.webhookUrl = trimmed
|
|
499
|
+
}
|
|
500
|
+
if ('imTargets' in patch) {
|
|
501
|
+
const list = patch.imTargets
|
|
502
|
+
if (!Array.isArray(list)) return { ok: false, reason: 'imTargets 须为数组' }
|
|
503
|
+
if (list.length > IM_TARGETS_MAX) return { ok: false, reason: 'imTargets 超过上限' }
|
|
504
|
+
const seen = new Set()
|
|
505
|
+
const targets = []
|
|
506
|
+
for (const item of list) {
|
|
507
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return { ok: false, reason: 'imTargets 项须为对象' }
|
|
508
|
+
const keys = Object.keys(item)
|
|
509
|
+
if (keys.length !== 2 || !keys.includes('botId') || !keys.includes('targetId')) return { ok: false, reason: 'imTargets 项仅含 botId 与 targetId' }
|
|
510
|
+
const botId = typeof item.botId === 'string' ? item.botId.trim() : ''
|
|
511
|
+
const targetId = typeof item.targetId === 'string' ? item.targetId.trim() : ''
|
|
512
|
+
if (!IM_BOT_ID_PATTERN.test(botId)) return { ok: false, reason: 'botId 格式非法' }
|
|
513
|
+
if (!IM_TARGET_ID_PATTERN.test(targetId)) return { ok: false, reason: 'targetId 格式非法' }
|
|
514
|
+
const key = botId + '/' + targetId
|
|
515
|
+
if (seen.has(key)) return { ok: false, reason: 'imTargets 存在重复项' }
|
|
516
|
+
seen.add(key)
|
|
517
|
+
targets.push({ botId, targetId })
|
|
518
|
+
}
|
|
519
|
+
next.imTargets = targets
|
|
520
|
+
}
|
|
521
|
+
if ('minTurnDurationMs' in patch) {
|
|
522
|
+
const ms = patch.minTurnDurationMs
|
|
523
|
+
if (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 0) return { ok: false, reason: 'minTurnDurationMs 须为非负整数' }
|
|
524
|
+
next.minTurnDurationMs = ms
|
|
525
|
+
}
|
|
526
|
+
if ('rootsOnly' in patch) {
|
|
527
|
+
if (typeof patch.rootsOnly !== 'boolean') return { ok: false, reason: 'rootsOnly 须为布尔' }
|
|
528
|
+
next.rootsOnly = patch.rootsOnly
|
|
529
|
+
}
|
|
530
|
+
if ('suppressSubagentWake' in patch) {
|
|
531
|
+
if (typeof patch.suppressSubagentWake !== 'boolean') return { ok: false, reason: 'suppressSubagentWake 须为布尔' }
|
|
532
|
+
next.suppressSubagentWake = patch.suppressSubagentWake
|
|
533
|
+
}
|
|
534
|
+
if ('enabled' in patch) {
|
|
535
|
+
const enabled = patch.enabled
|
|
536
|
+
if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
|
|
537
|
+
for (const key of Object.keys(enabled)) {
|
|
538
|
+
if (CATEGORIES.indexOf(key) < 0) return { ok: false, reason: '未知分类: ' + key }
|
|
539
|
+
if (typeof enabled[key] !== 'boolean') return { ok: false, reason: '分类开关须为布尔: ' + key }
|
|
540
|
+
}
|
|
541
|
+
next.enabled = { ...enabled }
|
|
542
|
+
}
|
|
543
|
+
return { ok: true, patch: next }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 面板读取的解析形态:enabled 缺省键按开补全,字段类型异常回退默认值;
|
|
547
|
+
// 时长统一取整到非负整数,与写路径校验宽松度一致(yaml 手写小数不产生读存差)。
|
|
548
|
+
// soundMapping 仅保留非空字符串值:settings.update 深合并无法物理删除嵌套键,
|
|
549
|
+
// 清除信号经 schema 归一落为 null 或空串,此处过滤使逻辑删除对下游透明。
|
|
550
|
+
export function resolvedConfig(settings) {
|
|
551
|
+
const source = settings || {}
|
|
552
|
+
const enabled = (typeof source.enabled === 'object' && source.enabled !== null) ? source.enabled : {}
|
|
553
|
+
const rawMapping = (typeof source.soundMapping === 'object' && source.soundMapping !== null) ? source.soundMapping : {}
|
|
554
|
+
const soundMapping = {}
|
|
555
|
+
for (const key of Object.keys(rawMapping)) {
|
|
556
|
+
const value = rawMapping[key]
|
|
557
|
+
if (typeof value === 'string' && value.length > 0) soundMapping[key] = value
|
|
558
|
+
}
|
|
559
|
+
const duration = Number.isFinite(source.minTurnDurationMs) ? Math.max(0, Math.floor(source.minTurnDurationMs)) : MIN_TURN_DURATION_MS
|
|
560
|
+
return {
|
|
561
|
+
webhookUrl: typeof source.webhookUrl === 'string' ? source.webhookUrl : '',
|
|
562
|
+
minTurnDurationMs: duration,
|
|
563
|
+
rootsOnly: source.rootsOnly !== false,
|
|
564
|
+
suppressSubagentWake: source.suppressSubagentWake !== false,
|
|
565
|
+
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
|
|
566
|
+
soundMapping,
|
|
567
|
+
imTargets: normalizeImTargets(source.imTargets),
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 面板可见配置:webhookUrl 属凭据不出主机,仅回是否已配置。
|
|
572
|
+
export function publicConfig(settings) {
|
|
573
|
+
const resolved = resolvedConfig(settings)
|
|
574
|
+
return {
|
|
575
|
+
minTurnDurationMs: resolved.minTurnDurationMs,
|
|
576
|
+
rootsOnly: resolved.rootsOnly,
|
|
577
|
+
suppressSubagentWake: resolved.suppressSubagentWake,
|
|
578
|
+
enabled: resolved.enabled,
|
|
579
|
+
soundMapping: resolved.soundMapping,
|
|
580
|
+
imTargets: resolved.imTargets,
|
|
581
|
+
webhookConfigured: resolved.webhookUrl.trim().length > 0,
|
|
582
|
+
}
|
|
583
|
+
}
|