@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/src/index.js ADDED
@@ -0,0 +1,660 @@
1
+ // dsh-turn-notify Host 半区:回合通知单源决策。观察 session/event 与 approval/request,
2
+ // 命中分类即发 webhook、写入内存投影;webServer 路由供浏览器半区轮询投影与管理音效。
3
+ // 音效持久化在 ~/.dsh/dsh-turn-notify/sounds/,投影不落盘。
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import z from '@deepseek-ai/schemastery'
10
+ import {
11
+ AUDIO_EXTS,
12
+ CATEGORIES,
13
+ CATEGORY_ASK,
14
+ CATEGORY_APPROVAL,
15
+ MIN_TURN_DURATION_MS,
16
+ OPEN_TURN_STALE_MS,
17
+ SUBAGENT_WAKE_WINDOW_MS,
18
+ buildUnit,
19
+ buildWebhookPayload,
20
+ collectSessionEvents,
21
+ createApprovalTap,
22
+ createProjection,
23
+ isSubagent,
24
+ isSubagentWakeTurn,
25
+ isValidImBotId,
26
+ mapEventToCategory,
27
+ mimeOf,
28
+ normalizeImTargets,
29
+ pruneTimestamps,
30
+ readRawBody,
31
+ resolvedConfig,
32
+ sendWebhook,
33
+ shouldNotify,
34
+ storedSessionTitle,
35
+ uploadExt,
36
+ UPLOAD_FILE_MAX_BYTES,
37
+ validateConfigPatch,
38
+ validateMappingId,
39
+ validateSoundName,
40
+ validateUpload,
41
+ WEBHOOK_TIMEOUT_MS,
42
+ publicConfig,
43
+ } from './core.mjs'
44
+
45
+ export const name = 'dsh-turn-notify'
46
+
47
+ export const inject = ['webServer']
48
+
49
+ const NAMESPACE = 'turn-notify'
50
+ const SOUNDS_DIR = join(homedir(), '.dsh', 'dsh-turn-notify', 'sounds')
51
+ const REQUEST_BODY_MAX_BYTES = 64 * 1024
52
+ // 长轮询挂起上限:低于客户端请求超时,保证客户端总在服务端放弃后才超时
53
+ const LONG_POLL_WAIT_MS = 25 * 1000
54
+
55
+ // dsh-im 投递错误码到 HTTP 状态的映射,未收录错误按网关失败处理
56
+ const IM_ERROR_STATUS = { 'bad-request': 400, 'unknown-bot': 404, 'bot-not-connected': 503 }
57
+
58
+ const TURN_END_KIND = 'turn/end'
59
+ const TOOL_CALL_KIND = 'tool/call'
60
+ const ASK_TOOL_NAME = 'ask_user_question'
61
+ const SUBAGENT_TOOL_NAME = 'subagent'
62
+
63
+ // 活动子代理回合回收:子会话异常终止不发 turn/end 时按超时释放,防父会话永久静默;
64
+ // 代价是运行超过回收时限的超长子代理会失去等待静默保护
65
+ const ACTIVE_CHILD_STALE_MS = OPEN_TURN_STALE_MS
66
+
67
+ // 活动子代理注册表遍历判定:是否存在挂在指定父会话名下的未收尾子代理回合
68
+ const hasActiveChild = (map, parentSessionId) => {
69
+ for (const entry of map.values()) {
70
+ if (entry.parent === parentSessionId) return true
71
+ }
72
+ return false
73
+ }
74
+
75
+ // 注册即声明 GUI 设置表单,schema 默认值即生效默认值;webhookUrl 属凭据标 secret。
76
+ const SETTINGS_SCHEMA = z.object({
77
+ webhookUrl: z.string().role('secret').default('').description('webhook 推送目标 URL,留空禁用'),
78
+ minTurnDurationMs: z.number().default(MIN_TURN_DURATION_MS).description('最短回合时长(毫秒),回合结束类通知短于此不送达;AI 提问与审批请求即时送达'),
79
+ rootsOnly: z.boolean().default(true).description('子代理会话不通知'),
80
+ suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
81
+ enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
82
+ soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
83
+ imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
84
+ })
85
+
86
+ // 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
87
+ const readSettings = (ctx) => {
88
+ const settings = ctx.get('settings')
89
+ return resolvedConfig(settings ? settings.get(NAMESPACE) : undefined)
90
+ }
91
+
92
+ function sendJson(res, status, payload) {
93
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
94
+ res.end(JSON.stringify(payload))
95
+ }
96
+
97
+ // 错误归一:fs 类错误(code 形如 EXXX)属服务端故障回 500,请求体与校验类错误回 400;
98
+ // fs 错误原文携带主机绝对路径,仅回错误码不回 message,防向面板访客泄漏目录结构
99
+ const FS_ERROR_CODE = /^E[A-Z]+$/
100
+ function sendError(res, error) {
101
+ const code = error && error.code
102
+ const isFsError = typeof code === 'string' && FS_ERROR_CODE.test(code)
103
+ const message = isFsError ? '服务端文件操作失败: ' + code : (error && error.message ? error.message : String(error))
104
+ sendJson(res, isFsError ? 500 : 400, { error: message })
105
+ }
106
+
107
+ // 路由样板收敛:方法守卫与可选跨源/JSON 守卫统一在此,业务异常统一归一,handler 只留业务体
108
+ const route = (method, guards, handler) => async (req, res) => {
109
+ if (req.method !== method) {
110
+ sendJson(res, 405, { error: 'method not allowed' })
111
+ return
112
+ }
113
+ if (guards.crossOrigin && rejectCrossOrigin(req, res)) return
114
+ if (guards.json && rejectNonJson(req, res)) return
115
+ try {
116
+ await handler(req, res)
117
+ } catch (error) {
118
+ sendError(res, error)
119
+ }
120
+ }
121
+
122
+ // 同源守卫:浏览器写请求恒带 Origin,与 Host 不符即拒;无 Origin 的非浏览器客户端放行。
123
+ // 配合 JSON 内容类型校验,阻断跨站简单请求 drive-by 改写配置。
124
+ function rejectCrossOrigin(req, res) {
125
+ const origin = req.headers ? req.headers.origin : undefined
126
+ if (!origin) return false
127
+ let sameOrigin = false
128
+ try {
129
+ sameOrigin = new URL(origin).host === req.headers.host
130
+ } catch {
131
+ sameOrigin = false
132
+ }
133
+ if (sameOrigin) return false
134
+ sendJson(res, 403, { error: '跨源请求被拒绝' })
135
+ return true
136
+ }
137
+
138
+ function rejectNonJson(req, res) {
139
+ const contentType = req.headers ? String(req.headers['content-type'] || '') : ''
140
+ if (contentType.indexOf('application/json') >= 0) return false
141
+ sendJson(res, 400, { error: 'content-type 须为 application/json' })
142
+ return true
143
+ }
144
+
145
+ const soundId = (content) => 'snd-' + createHash('sha256').update(content).digest('hex').slice(0, 16)
146
+ const soundPath = (id, ext) => join(SOUNDS_DIR, id + '.' + ext)
147
+
148
+ // 展示名索引:文件名恒为内容哈希 id,重命名只改展示名,索引与音效同目录同生命周期
149
+ const SOUND_INDEX_NAME = 'index.json'
150
+ const soundIndexPath = join(SOUNDS_DIR, SOUND_INDEX_NAME)
151
+
152
+ // 索引读取:缺失或损坏一律回空对象(丢名不丢音),仅保留非空字符串值的键
153
+ async function readSoundIndex() {
154
+ try {
155
+ const parsed = JSON.parse(await readFile(soundIndexPath, 'utf8'))
156
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
157
+ const index = {}
158
+ for (const key of Object.keys(parsed)) {
159
+ if (typeof parsed[key] === 'string' && parsed[key].length > 0) index[key] = parsed[key]
160
+ }
161
+ return index
162
+ } catch {
163
+ return {}
164
+ }
165
+ }
166
+
167
+ // 索引写入:id 到展示名的一比一对象,调用方保证处于互斥段
168
+ const writeSoundIndex = (index) => writeFile(soundIndexPath, JSON.stringify(index))
169
+
170
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
171
+ export function apply(ctx) {
172
+ const projection = createProjection({})
173
+ // 打开回合:按会话 id 记回合序号与起始时间,回合结束即清;序号严格递增,
174
+ // 供子代理注册归属判定,同毫秒内多回合不受时钟精度歧义影响
175
+ const openTurns = new Map()
176
+ let turnSeq = 0
177
+ // 子代理会话结束时刻,按父会话 id 记录,用于识别子代理回执唤醒的回合
178
+ const childDoneAt = new Map()
179
+ // 被子代理回执唤醒的回合,回合结束即清
180
+ const wakeTurns = new Set()
181
+ // 活动子代理回合:子会话开跑注册、收尾注销,按子会话 id 记父会话与注册时父回合序号
182
+ const childActive = new Map()
183
+ // 回合级委托计数:本回合内 subagent 工具调用次数,回合结束即清
184
+ const turnDelegation = new Map()
185
+ // 回合级子代理收尾计数:本回合注册的子代理在父回合开启期间收尾(前台委托完成),回合结束即清;
186
+ // 计数对比豁免混合委托回合(前台已完成不抵扣未收尾的后台委托)
187
+ const turnChildSettled = new Map()
188
+ // 事件读序:标题提取需要事件流,按会话累积 user/message 文本;标题封账后不再累积
189
+ const sessionEvents = new Map()
190
+ const titledSessions = new Set()
191
+ let seq = 0
192
+ // 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
193
+ let soundWriteQueue = Promise.resolve()
194
+ const serializedSoundWrite = (fn) => {
195
+ const next = soundWriteQueue.then(fn, fn)
196
+ soundWriteQueue = next.then(() => {}, () => {})
197
+ return next
198
+ }
199
+
200
+ // dshIm 在场判定:可选依赖运行期探测,各路由统一由此取语义
201
+ const imReady = () => ctx.get('dshIm') !== undefined
202
+
203
+ // 测试事件构造:test-webhook 与 test-im 共用同一形态,防两处样例走样
204
+ const buildTestUnit = () => {
205
+ seq += 1
206
+ return buildUnit({
207
+ id: 'test-' + Date.now().toString(36) + '-' + String(seq),
208
+ category: CATEGORY_APPROVAL,
209
+ status: CATEGORY_ASK,
210
+ sessionTitle: '测试事件',
211
+ workspace: '',
212
+ durationMs: null,
213
+ ts: Date.now(),
214
+ })
215
+ }
216
+
217
+ // durationMs 与 wakeTurn / awaitingChildren 由调用方在清理状态前读取后传入,防先删后读
218
+ function notifyUnit({ category, kind, reasonKind, session, durationMs = null, wakeTurn = false, awaitingChildren = false }) {
219
+ const settings = readSettings(ctx)
220
+ const header = session.header || {}
221
+ const sessionId = String(session.id ?? '')
222
+ if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
223
+ seq += 1
224
+ const unit = buildUnit({
225
+ id: 'n-' + Date.now().toString(36) + '-' + String(seq) + '-' + String(category),
226
+ category,
227
+ status: reasonKind ?? category,
228
+ sessionTitle: sessionDisplayTitle(session, sessionId),
229
+ workspace: typeof header.cwd === 'string' ? header.cwd : '',
230
+ durationMs,
231
+ ts: Date.now(),
232
+ })
233
+ projection.push(unit)
234
+ void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
235
+ deliverIm(unit, settings)
236
+ }
237
+
238
+ // 会话显示标题:优先 session-title 服务(与侧边栏行标题同一投影,客户端文本匹配高亮
239
+ // 才成立),服务缺失或会话无事件日志时回落首条消息文本
240
+ function sessionDisplayTitle(session, sessionId) {
241
+ const service = ctx.get('sessionTitle')
242
+ if (service !== undefined) {
243
+ try {
244
+ const snapshot = service.get(session)
245
+ if (snapshot !== undefined && snapshot !== null && typeof snapshot.title === 'string' && snapshot.title.length > 0) return snapshot.title
246
+ } catch { /* 伪会话(如审批 waterfall)无事件日志,走回落 */ }
247
+ }
248
+ return storedSessionTitle(sessionEvents, sessionId)
249
+ }
250
+
251
+ // IM 投递:多目标逐发,fire-and-forget 不重试,失败即弃,与 webhook 同语义;
252
+ // dshIm 经运行期可选读取,未装 dsh-im 的 profile 本插件照常工作;
253
+ // 调用推迟到微任务使同步异常同样被吞;超时与 test-im 同值,防平台挂起堆积
254
+ function deliverIm(unit, settings) {
255
+ const dshIm = ctx.get('dshIm')
256
+ if (dshIm === undefined) return
257
+ for (const { botId, targetId } of normalizeImTargets(settings.imTargets)) {
258
+ void Promise.resolve().then(() => dshIm.send(botId, targetId, unit.text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })).catch(() => {})
259
+ }
260
+ }
261
+
262
+ // 权威事件流:turn/start 记时,turn/end 与 ask_user_question tool/call 命中分类
263
+ ctx.on('session/event', (session, event) => {
264
+ const sessionId = String(session.id ?? '')
265
+ const now = Date.now()
266
+ const header = session.header || {}
267
+ const parentSession = typeof header.parentSession === 'string' ? header.parentSession : null
268
+ // 异常时序残留的惰性回收:超唤醒窗口的子代理回执、超上限的回合起始与活动子代理顺带清扫;
269
+ // 本回合的 start 先捕获,防 prune 误删当前超长回合的起始记录
270
+ const currentTurnStart = openTurns.get(sessionId)
271
+ pruneTimestamps(childDoneAt, now, SUBAGENT_WAKE_WINDOW_MS)
272
+ for (const [turnId, turn] of openTurns) {
273
+ if (now - turn.at > OPEN_TURN_STALE_MS) openTurns.delete(turnId)
274
+ }
275
+ for (const [childId, entry] of childActive) {
276
+ if (now - entry.at > ACTIVE_CHILD_STALE_MS) childActive.delete(childId)
277
+ }
278
+ // turn/end 清理状态前捕获的决策输入,仅回分类通知消费
279
+ let endedTurn = null
280
+ if (event.type === 'user/message') {
281
+ collectSessionEvents(sessionEvents, titledSessions, sessionId, event)
282
+ return
283
+ }
284
+ if (event.type === 'turn/start') {
285
+ // 回合级标记重置:上一回合的委托与收尾残留不跨回合
286
+ turnDelegation.delete(sessionId)
287
+ turnChildSettled.delete(sessionId)
288
+ // 子代理结束后窗口内父会话开新回合 = 回执唤醒回合;标记即消费,仅首个回合生效。
289
+ // 依赖运行时契约:子代理 turn/end 先于父会话唤醒 turn/start 到达。
290
+ const done = childDoneAt.get(sessionId)
291
+ if (done !== undefined) {
292
+ childDoneAt.delete(sessionId)
293
+ if (isSubagentWakeTurn({ childDoneAt: done, turnStartMs: now })) wakeTurns.add(sessionId)
294
+ }
295
+ // 子代理会话开跑:注册到父会话名下并记注册时父回合序号,供其收尾的归属判定;
296
+ // 父回合已结束(后台委托挂起)则序号为空,收尾不归属任何开启回合
297
+ if (isSubagent(header) && parentSession !== null) {
298
+ childActive.set(sessionId, { parent: parentSession, at: now, turnSeq: openTurns.get(parentSession)?.seq })
299
+ }
300
+ openTurns.set(sessionId, { seq: ++turnSeq, at: now })
301
+ return
302
+ }
303
+ if (event.type === TURN_END_KIND) {
304
+ // 等待子代理判定:有未收尾子代理,或委托计数超出本回合内收尾计数
305
+ // (覆盖子代理 turn/start 晚于主回合结束的启动竞态与启动失败),仅压 completed 分类
306
+ const awaitingChildren = hasActiveChild(childActive, sessionId)
307
+ || (turnDelegation.get(sessionId) ?? 0) > (turnChildSettled.get(sessionId) ?? 0)
308
+ // 先读后清:通知决策需要回合时长(相对 start 的差值)与唤醒标记;
309
+ // 无 start 记录(如观察器中途装载)时长置 null,碎轮过滤跳过
310
+ endedTurn = {
311
+ durationMs: currentTurnStart === undefined ? null : now - currentTurnStart.at,
312
+ wakeTurn: wakeTurns.has(sessionId),
313
+ awaitingChildren,
314
+ }
315
+ turnDelegation.delete(sessionId)
316
+ turnChildSettled.delete(sessionId)
317
+ // 子代理会话收尾:记录到父会话名下,供其唤醒回合判定;注销活动注册;
318
+ // 注册序号等于父回合当前序号(本回合发起的委托)才计入回合收尾,
319
+ // 跨回合陈旧子代理的收尾不抵扣本回合未收尾的委托
320
+ if (isSubagent(header) && parentSession !== null) {
321
+ childDoneAt.set(parentSession, now)
322
+ const registeredTurnSeq = childActive.get(sessionId)?.turnSeq
323
+ childActive.delete(sessionId)
324
+ if (registeredTurnSeq !== undefined && openTurns.get(parentSession)?.seq === registeredTurnSeq) {
325
+ turnChildSettled.set(parentSession, (turnChildSettled.get(parentSession) ?? 0) + 1)
326
+ }
327
+ }
328
+ openTurns.delete(sessionId)
329
+ wakeTurns.delete(sessionId)
330
+ // 父会话收尾即不再有唤醒判定意义
331
+ childDoneAt.delete(sessionId)
332
+ }
333
+ if (event.type === TOOL_CALL_KIND) {
334
+ if (event.data && event.data.name === SUBAGENT_TOOL_NAME) turnDelegation.set(sessionId, (turnDelegation.get(sessionId) ?? 0) + 1)
335
+ if (!event.data || event.data.name !== ASK_TOOL_NAME) return
336
+ }
337
+ const category = mapEventToCategory(event.type, event.data)
338
+ if (category === null) return
339
+ const reasonKind = event.type === TURN_END_KIND && event.data.reason ? event.data.reason.kind : null
340
+ // tool/call 的提问事件与 turn/end 分类共用入口;提问通知不受碎轮过滤
341
+ const kind = category === CATEGORY_ASK ? TOOL_CALL_KIND : TURN_END_KIND
342
+ notifyUnit({ category, kind, reasonKind, session, ...(endedTurn ?? {}) })
343
+ })
344
+
345
+ // 审批 waterfall 观察者:next() 同步放行,通知异步投递
346
+ ctx.on('approval/request', createApprovalTap(
347
+ ({ category }) => {
348
+ // waterfall 请求不携带会话对象,标题与工作区留空,仅分类与时间有效
349
+ notifyUnit({ category, kind: 'approval/request', reasonKind: category, session: { header: {} } })
350
+ },
351
+ (fn) => { fn() },
352
+ ))
353
+
354
+ ctx.inject(['settings'], (settingsCtx) => {
355
+ settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
356
+ })
357
+
358
+ const listSounds = async () => {
359
+ await mkdir(SOUNDS_DIR, { recursive: true })
360
+ const names = await readdir(SOUNDS_DIR)
361
+ const index = await readSoundIndex()
362
+ return names
363
+ // 扩展名白名单:索引文件与任何非音频杂项一律不入列
364
+ .filter((name) => AUDIO_EXTS.indexOf(uploadExt(name)) >= 0)
365
+ .map((name) => {
366
+ const dot = name.indexOf('.')
367
+ const id = name.slice(0, dot)
368
+ return { id, ext: name.slice(dot + 1), name: index[id] ?? null }
369
+ })
370
+ }
371
+
372
+ // 长轮询:cursor 缺省=首拉立即返回;仅版本恰等时挂起至事件 push / 配置变更 bump / 超时;
373
+ // cursor 超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈。
374
+ ctx.effect(() => {
375
+ ctx.webServer.register({
376
+ kind: 'exact',
377
+ path: '/api/turn-notify/projection',
378
+ handler: route('GET', {}, async (req, res) => {
379
+ const query = new URL(req.url, 'http://localhost').searchParams
380
+ const rawCursor = Number(query.get('cursor'))
381
+ const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
382
+ if (query.has('cursor') && projection.version() === cursor) {
383
+ await projection.wait(cursor, LONG_POLL_WAIT_MS)
384
+ }
385
+ const settings = readSettings(ctx)
386
+ sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
387
+ }),
388
+ })
389
+ return () => projection.dispose()
390
+ }, 'turn-notify projection route')
391
+
392
+ ctx.effect(() =>
393
+ ctx.webServer.register({
394
+ kind: 'exact',
395
+ path: '/api/turn-notify/sounds',
396
+ handler: route('GET', {}, async (req, res) => {
397
+ sendJson(res, 200, { sounds: await listSounds(), builtin: true })
398
+ }),
399
+ }), 'turn-notify sounds route')
400
+
401
+ ctx.effect(() =>
402
+ ctx.webServer.register({
403
+ kind: 'exact',
404
+ path: '/api/turn-notify/upload',
405
+ handler: route('POST', { crossOrigin: true }, async (req, res) => {
406
+ const url = new URL(req.url, 'http://localhost')
407
+ const filename = url.searchParams.get('name') || ''
408
+ // 互斥段外仅读请求体;配额统计到落盘整段串行,防并发上传各自按旧总量绕过上限
409
+ const body = await readRawBody(req, UPLOAD_FILE_MAX_BYTES)
410
+ const id = soundId(body)
411
+ await serializedSoundWrite(async () => {
412
+ const sounds = await listSounds()
413
+ // 内容寻址幂等:同内容已存在(任意扩展名)直接返回既有 id,不产生双文件;
414
+ // 先于配额校验放行——不新增字节,扩展名差异不改变存储事实
415
+ if (sounds.some((item) => item.id === id)) {
416
+ sendJson(res, 200, { ok: true, id })
417
+ return
418
+ }
419
+ let totalBytes = 0
420
+ for (const sound of sounds) {
421
+ try { totalBytes += (await stat(join(SOUNDS_DIR, sound.id + '.' + sound.ext))).size } catch { /* 与删除并发竞态按零计 */ }
422
+ }
423
+ const verdict = validateUpload({ filename, size: body.length, totalBytes })
424
+ if (!verdict.ok) {
425
+ sendJson(res, 400, { error: verdict.reason })
426
+ return
427
+ }
428
+ await mkdir(SOUNDS_DIR, { recursive: true })
429
+ await writeFile(soundPath(id, verdict.ext), body)
430
+ sendJson(res, 200, { ok: true, id })
431
+ })
432
+ }),
433
+ }), 'turn-notify upload route')
434
+
435
+ ctx.effect(() =>
436
+ ctx.webServer.register({
437
+ kind: 'exact',
438
+ path: '/api/turn-notify/sound',
439
+ handler: async (req, res) => {
440
+ try {
441
+ await dispatchSound(req, res)
442
+ } catch (error) {
443
+ sendError(res, error)
444
+ }
445
+ },
446
+ }), 'turn-notify sound route')
447
+
448
+ // sound 路由三方法分发:GET 读取 / PUT 重命名 / DELETE 删除,守卫差异在分支内声明
449
+ async function dispatchSound(req, res) {
450
+ const url = new URL(req.url, 'http://localhost')
451
+ const id = url.searchParams.get('id') || ''
452
+ if (req.method === 'GET') {
453
+ const sounds = await listSounds()
454
+ const sound = sounds.find((item) => item.id === id)
455
+ if (!sound) {
456
+ sendJson(res, 404, { error: '音效不存在' })
457
+ return
458
+ }
459
+ const content = await readFile(join(SOUNDS_DIR, id + '.' + sound.ext))
460
+ res.writeHead(200, { 'content-type': mimeOf(sound.ext) })
461
+ res.end(content)
462
+ return
463
+ }
464
+ // 重命名:文件名恒为内容哈希 id,改名只写展示名索引,映射引用的 id 不随之变化
465
+ if (req.method === 'PUT') {
466
+ if (rejectCrossOrigin(req, res) || rejectNonJson(req, res)) return
467
+ const body = JSON.parse((await readRawBody(req, REQUEST_BODY_MAX_BYTES)).toString('utf8'))
468
+ const renameId = body && typeof body.id === 'string' ? body.id : ''
469
+ // 互斥段覆盖索引读到写回,防并发重命名互相覆盖
470
+ await serializedSoundWrite(async () => {
471
+ const sound = (await listSounds()).find((item) => item.id === renameId)
472
+ if (!sound) {
473
+ sendJson(res, 404, { error: '音效不存在' })
474
+ return
475
+ }
476
+ const verdict = validateSoundName(body && typeof body.name === 'string' ? body.name : '')
477
+ if (!verdict.ok) {
478
+ sendJson(res, 400, { error: verdict.reason })
479
+ return
480
+ }
481
+ // 索引当前值已等于目标名:幂等提前返回
482
+ if (sound.name === verdict.name) {
483
+ sendJson(res, 200, { ok: true, id: renameId, name: verdict.name })
484
+ return
485
+ }
486
+ const index = await readSoundIndex()
487
+ index[renameId] = verdict.name
488
+ await writeSoundIndex(index)
489
+ sendJson(res, 200, { ok: true, id: renameId, name: verdict.name })
490
+ })
491
+ return
492
+ }
493
+ if (req.method !== 'DELETE') {
494
+ sendJson(res, 405, { error: 'method not allowed' })
495
+ return
496
+ }
497
+ if (rejectCrossOrigin(req, res)) return
498
+ // 与 PUT 同互斥:文件删除与索引条目清理同段完成,防并发读改写交错
499
+ await serializedSoundWrite(async () => {
500
+ const sound = (await listSounds()).find((item) => item.id === id)
501
+ if (!sound) {
502
+ sendJson(res, 404, { error: '音效不存在' })
503
+ return
504
+ }
505
+ await unlink(join(SOUNDS_DIR, id + '.' + sound.ext))
506
+ // 展示名条目随文件一并清理,防索引滞留死条目
507
+ const index = await readSoundIndex()
508
+ if (Object.prototype.hasOwnProperty.call(index, id)) {
509
+ delete index[id]
510
+ await writeSoundIndex(index)
511
+ }
512
+ // 被引用即回落:深合并语义下引用置 null 清除,resolveSound 兜底回内置默认
513
+ const settings = ctx.get('settings')
514
+ if (settings) {
515
+ const current = readSettings(ctx)
516
+ const patch = {}
517
+ for (const key of Object.keys(current.soundMapping)) {
518
+ if (current.soundMapping[key] === id) patch[key] = null
519
+ }
520
+ if (Object.keys(patch).length > 0) {
521
+ await settings.update(NAMESPACE, { soundMapping: patch })
522
+ projection.bump()
523
+ }
524
+ }
525
+ sendJson(res, 200, { ok: true })
526
+ })
527
+ }
528
+
529
+ ctx.effect(() =>
530
+ ctx.webServer.register({
531
+ kind: 'exact',
532
+ path: '/api/turn-notify/mapping',
533
+ handler: route('POST', { crossOrigin: true, json: true }, async (req, res) => {
534
+ const body = JSON.parse((await readRawBody(req, REQUEST_BODY_MAX_BYTES)).toString('utf8'))
535
+ const category = body && typeof body.category === 'string' ? body.category : ''
536
+ const id = body && typeof body.id === 'string' ? body.id : ''
537
+ if (CATEGORIES.indexOf(category) < 0) {
538
+ sendJson(res, 400, { error: '未知分类: ' + category })
539
+ return
540
+ }
541
+ const settings = ctx.get('settings')
542
+ if (!settings) {
543
+ sendJson(res, 500, { error: 'settings 服务不可用' })
544
+ return
545
+ }
546
+ // 校验到写回整段入互斥域:防与 DELETE 清引用并发时把已删音效的 id 写回映射;
547
+ // settings.update 深合并无法删键,清除以 null 表达,读侧 resolvedConfig 过滤
548
+ await serializedSoundWrite(async () => {
549
+ if (!validateMappingId(id, (await listSounds()).map((sound) => sound.id))) {
550
+ sendJson(res, 400, { error: '未知音效: ' + id })
551
+ return
552
+ }
553
+ await settings.update(NAMESPACE, { soundMapping: { [category]: id.length === 0 ? null : id } })
554
+ projection.bump()
555
+ sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx).soundMapping })
556
+ })
557
+ }),
558
+ }), 'turn-notify mapping route')
559
+
560
+ ctx.effect(() =>
561
+ ctx.webServer.register({
562
+ kind: 'exact',
563
+ path: '/api/turn-notify/config',
564
+ // GET/POST 双方法且守卫差异在 POST 分支,不经单方法 route() 包装
565
+ handler: async (req, res) => {
566
+ // imAvailable 不进 core 纯函数,config 响应处合流;实时判定,无装载时序假设
567
+ if (req.method === 'GET') {
568
+ sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
569
+ return
570
+ }
571
+ if (req.method !== 'POST') {
572
+ sendJson(res, 405, { error: 'method not allowed' })
573
+ return
574
+ }
575
+ if (rejectCrossOrigin(req, res) || rejectNonJson(req, res)) return
576
+ try {
577
+ const verdict = validateConfigPatch(JSON.parse((await readRawBody(req, REQUEST_BODY_MAX_BYTES)).toString('utf8')))
578
+ if (!verdict.ok) {
579
+ sendJson(res, 400, { error: verdict.reason })
580
+ return
581
+ }
582
+ const settings = ctx.get('settings')
583
+ if (!settings) {
584
+ sendJson(res, 500, { error: 'settings 服务不可用' })
585
+ return
586
+ }
587
+ await settings.update(NAMESPACE, verdict.patch)
588
+ projection.bump()
589
+ sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
590
+ } catch (error) {
591
+ sendError(res, error)
592
+ }
593
+ },
594
+ }), 'turn-notify config route')
595
+
596
+ ctx.effect(() =>
597
+ ctx.webServer.register({
598
+ kind: 'exact',
599
+ path: '/api/turn-notify/test-webhook',
600
+ handler: route('POST', { crossOrigin: true }, async (req, res) => {
601
+ // 等待投递完成,真实结果随响应返回,测试按钮不再谎报
602
+ const result = await sendWebhook({ url: readSettings(ctx).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
603
+ sendJson(res, 200, result)
604
+ }),
605
+ }), 'turn-notify test-webhook route')
606
+
607
+ ctx.effect(() =>
608
+ ctx.webServer.register({
609
+ kind: 'exact',
610
+ path: '/api/turn-notify/im-targets',
611
+ handler: route('GET', {}, async (req, res) => {
612
+ const dshIm = ctx.get('dshIm')
613
+ if (dshIm === undefined) {
614
+ sendJson(res, 503, { error: 'dsh-im 未安装' })
615
+ return
616
+ }
617
+ const botId = new URL(req.url, 'http://localhost').searchParams.get('botId') || ''
618
+ if (!isValidImBotId(botId)) {
619
+ sendJson(res, 400, { error: 'botId 缺失或非法' })
620
+ return
621
+ }
622
+ try {
623
+ const targets = await dshIm.listTargets(botId)
624
+ // route 为平台原生路由 ID,选择目标无需知道,不出主机
625
+ sendJson(res, 200, { targets: targets.map(({ targetId, name, kind }) => ({ targetId, name, kind })) })
626
+ } catch (error) {
627
+ const code = error && error.code ? error.code : 'delivery-failed'
628
+ sendJson(res, IM_ERROR_STATUS[code] ?? 502, { error: code })
629
+ }
630
+ }),
631
+ }), 'turn-notify im-targets route')
632
+
633
+ ctx.effect(() =>
634
+ ctx.webServer.register({
635
+ kind: 'exact',
636
+ path: '/api/turn-notify/test-im',
637
+ handler: route('POST', { crossOrigin: true }, async (req, res) => {
638
+ const dshIm = ctx.get('dshIm')
639
+ if (dshIm === undefined) {
640
+ sendJson(res, 200, { ok: false, detail: 'dsh-im 未安装' })
641
+ return
642
+ }
643
+ const targets = normalizeImTargets(readSettings(ctx).imTargets)
644
+ if (targets.length === 0) {
645
+ sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
646
+ return
647
+ }
648
+ // 逐目标结算,真实结果随响应返回,与 test-webhook 的不谎报原则一致
649
+ const results = await Promise.all(targets.map(async ({ botId, targetId }) => {
650
+ try {
651
+ await dshIm.send(botId, targetId, buildTestUnit().text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })
652
+ return { botId, targetId, ok: true, detail: 'sent' }
653
+ } catch (error) {
654
+ return { botId, targetId, ok: false, detail: error && error.code ? error.code : 'delivery-failed' }
655
+ }
656
+ }))
657
+ sendJson(res, 200, { ok: results.every((item) => item.ok), results })
658
+ }),
659
+ }), 'turn-notify test-im route')
660
+ }
@@ -0,0 +1,16 @@
1
+ // client.js 注册 id 守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败。
2
+ import { readFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
10
+
11
+ test('client.js 注册 id 为完整包名', () => {
12
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
13
+ const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
14
+ assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
15
+ assert.equal(match[1], name)
16
+ })