@mzzsfy/dsh-turn-notify 0.12.0 → 0.12.1
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 +1 -1
- package/package.json +1 -1
- package/src/index.js +66 -22
- package/test/route.test.mjs +138 -0
package/README.md
CHANGED
|
@@ -192,4 +192,4 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
|
|
|
192
192
|
|
|
193
193
|
## dsh 版本兼容
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:通知开关与播报形态(四 tab 计数、事件开关、音效卡试听、IM 自洽)、激活 live。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -103,9 +103,51 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
103
103
|
})
|
|
104
104
|
|
|
105
105
|
// 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
|
|
106
|
-
const readSettings = (ctx) => {
|
|
106
|
+
const readSettings = (ctx, config) => {
|
|
107
107
|
const settings = ctx.get('settings')
|
|
108
|
-
|
|
108
|
+
// 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
|
|
109
|
+
// config 整节解包。方法面守卫兼防:服务在但缺 get 时回落默认配置,防路由 handler 抛错
|
|
110
|
+
return resolvedConfig(settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
|
|
114
|
+
// legacy 宿主 config 为普通对象原样透传
|
|
115
|
+
function unwrapVolatile(value) {
|
|
116
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构)。根级 volatile
|
|
120
|
+
// 包装:整节 live 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile
|
|
121
|
+
// 方法,特性检测原样返回
|
|
122
|
+
export const Config = typeof SETTINGS_SCHEMA.volatile === 'function' ? SETTINGS_SCHEMA.volatile() : SETTINGS_SCHEMA
|
|
123
|
+
|
|
124
|
+
// 0.1.7 写路径:configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
|
|
125
|
+
// 嵌套补丁(enabled.{category}/soundMapping.{category})须深合并:浅展开会把未提交
|
|
126
|
+
// 的兄弟分类键抹成 undefined;null 值原样落盘(清除语义,读侧 resolvedConfig 过滤)
|
|
127
|
+
async function persistPatch(ctx, patch) {
|
|
128
|
+
const settings = ctx.get('settings')
|
|
129
|
+
// legacy 方法面以 register 为准(0.1.7 的 settings.update 名义在场但按命名
|
|
130
|
+
// 空间找条目必败,不可作判据)
|
|
131
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
132
|
+
await settings.update(NAMESPACE, patch)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const editor = ctx.get('configEditor')
|
|
136
|
+
const entry = ctx.fiber?.entry
|
|
137
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('settings 服务不可用')
|
|
138
|
+
await editor.edit(entry, (current) => deepMerge(current, patch))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function deepMerge(under, over) {
|
|
142
|
+
const out = { ...under }
|
|
143
|
+
for (const key of Object.keys(over)) {
|
|
144
|
+
const next = over[key]
|
|
145
|
+
const underValue = out[key]
|
|
146
|
+
const bothPlain = (next !== null && typeof next === 'object' && !Array.isArray(next))
|
|
147
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
148
|
+
out[key] = bothPlain ? deepMerge(underValue, next) : next
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
109
151
|
}
|
|
110
152
|
|
|
111
153
|
function sendJson(res, status, payload) {
|
|
@@ -187,7 +229,7 @@ async function readSoundIndex() {
|
|
|
187
229
|
const writeSoundIndex = (index) => writeFile(soundIndexPath, JSON.stringify(index))
|
|
188
230
|
|
|
189
231
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
190
|
-
export function apply(ctx) {
|
|
232
|
+
export function apply(ctx, config) {
|
|
191
233
|
const projection = createProjection({})
|
|
192
234
|
// 打开回合:按会话 id 记回合序号与起始时间,回合结束即清;序号严格递增,
|
|
193
235
|
// 供子代理注册归属判定,同毫秒内多回合不受时钟精度歧义影响
|
|
@@ -241,7 +283,7 @@ export function apply(ctx) {
|
|
|
241
283
|
|
|
242
284
|
// durationMs 与 wakeTurn / awaitingChildren 由调用方在清理状态前读取后传入,防先删后读
|
|
243
285
|
function notifyUnit({ category, kind, reasonKind, session, durationMs = null, wakeTurn = false, awaitingChildren = false }) {
|
|
244
|
-
const settings = readSettings(ctx)
|
|
286
|
+
const settings = readSettings(ctx, config)
|
|
245
287
|
const header = session.header || {}
|
|
246
288
|
const sessionId = String(session.id ?? '')
|
|
247
289
|
if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
|
|
@@ -419,6 +461,14 @@ export function apply(ctx) {
|
|
|
419
461
|
))
|
|
420
462
|
|
|
421
463
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
464
|
+
// 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
|
|
465
|
+
// 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
|
|
466
|
+
if (typeof settingsCtx.settings.register !== 'function') {
|
|
467
|
+
if (typeof settingsCtx.settings?.configure === 'function') {
|
|
468
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
469
|
+
}
|
|
470
|
+
return
|
|
471
|
+
}
|
|
422
472
|
settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
|
|
423
473
|
})
|
|
424
474
|
|
|
@@ -471,7 +521,7 @@ export function apply(ctx) {
|
|
|
471
521
|
if (query.has('cursor') && projection.version() === cursor) {
|
|
472
522
|
await projection.wait(cursor, LONG_POLL_WAIT_MS)
|
|
473
523
|
}
|
|
474
|
-
const settings = readSettings(ctx)
|
|
524
|
+
const settings = readSettings(ctx, config)
|
|
475
525
|
sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
|
|
476
526
|
} finally {
|
|
477
527
|
if (releaseBrowserPoll !== null) releaseBrowserPoll()
|
|
@@ -602,15 +652,14 @@ export function apply(ctx) {
|
|
|
602
652
|
await writeSoundIndex(index)
|
|
603
653
|
}
|
|
604
654
|
// 被引用即回落:深合并语义下引用置 null 清除,resolveSound 兜底回内置默认
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const current = readSettings(ctx)
|
|
655
|
+
if (ctx.get('settings')) {
|
|
656
|
+
const current = readSettings(ctx, config)
|
|
608
657
|
const patch = {}
|
|
609
658
|
for (const key of Object.keys(current.soundMapping)) {
|
|
610
659
|
if (current.soundMapping[key] === id) patch[key] = null
|
|
611
660
|
}
|
|
612
661
|
if (Object.keys(patch).length > 0) {
|
|
613
|
-
await
|
|
662
|
+
await persistPatch(ctx, { soundMapping: patch })
|
|
614
663
|
projection.bump()
|
|
615
664
|
}
|
|
616
665
|
}
|
|
@@ -636,15 +685,15 @@ export function apply(ctx) {
|
|
|
636
685
|
return
|
|
637
686
|
}
|
|
638
687
|
// 校验到写回整段入互斥域:防与 DELETE 清引用并发时把已删音效的 id 写回映射;
|
|
639
|
-
//
|
|
688
|
+
// 深合并无法删键,清除以 null 表达,读侧 resolvedConfig 过滤
|
|
640
689
|
await serializedSoundWrite(async () => {
|
|
641
690
|
if (!validateMappingId(id, (await listSounds()).map((sound) => sound.id))) {
|
|
642
691
|
sendJson(res, 400, { error: '未知音效: ' + id })
|
|
643
692
|
return
|
|
644
693
|
}
|
|
645
|
-
await
|
|
694
|
+
await persistPatch(ctx, { soundMapping: { [category]: id.length === 0 ? null : id } })
|
|
646
695
|
projection.bump()
|
|
647
|
-
sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx).soundMapping })
|
|
696
|
+
sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx, config).soundMapping })
|
|
648
697
|
})
|
|
649
698
|
}),
|
|
650
699
|
}), 'turn-notify mapping route')
|
|
@@ -657,7 +706,7 @@ export function apply(ctx) {
|
|
|
657
706
|
handler: async (req, res) => {
|
|
658
707
|
// imAvailable 不进 core 纯函数,config 响应处合流;实时判定,无装载时序假设
|
|
659
708
|
if (req.method === 'GET') {
|
|
660
|
-
sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
|
|
709
|
+
sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
|
|
661
710
|
return
|
|
662
711
|
}
|
|
663
712
|
if (req.method !== 'POST') {
|
|
@@ -671,14 +720,9 @@ export function apply(ctx) {
|
|
|
671
720
|
sendJson(res, 400, { error: verdict.reason })
|
|
672
721
|
return
|
|
673
722
|
}
|
|
674
|
-
|
|
675
|
-
if (!settings) {
|
|
676
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
677
|
-
return
|
|
678
|
-
}
|
|
679
|
-
await settings.update(NAMESPACE, verdict.patch)
|
|
723
|
+
await persistPatch(ctx, verdict.patch)
|
|
680
724
|
projection.bump()
|
|
681
|
-
sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
|
|
725
|
+
sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
|
|
682
726
|
} catch (error) {
|
|
683
727
|
sendError(res, error)
|
|
684
728
|
}
|
|
@@ -691,7 +735,7 @@ export function apply(ctx) {
|
|
|
691
735
|
path: '/api/turn-notify/test-webhook',
|
|
692
736
|
handler: route('POST', { crossOrigin: true }, async (req, res) => {
|
|
693
737
|
// 等待投递完成,真实结果随响应返回,测试按钮不再谎报
|
|
694
|
-
const result = await sendWebhook({ url: readSettings(ctx).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
|
|
738
|
+
const result = await sendWebhook({ url: readSettings(ctx, config).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
|
|
695
739
|
sendJson(res, 200, result)
|
|
696
740
|
}),
|
|
697
741
|
}), 'turn-notify test-webhook route')
|
|
@@ -755,7 +799,7 @@ export function apply(ctx) {
|
|
|
755
799
|
sendJson(res, 200, { ok: false, detail: 'dsh-im 未安装' })
|
|
756
800
|
return
|
|
757
801
|
}
|
|
758
|
-
const targets = normalizeImTargets(readSettings(ctx).imTargets)
|
|
802
|
+
const targets = normalizeImTargets(readSettings(ctx, config).imTargets)
|
|
759
803
|
if (targets.length === 0) {
|
|
760
804
|
sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
|
|
761
805
|
return
|
package/test/route.test.mjs
CHANGED
|
@@ -413,3 +413,141 @@ test('test-webhook 已配置时送达并返回真实结果', async () => {
|
|
|
413
413
|
assert.ok(String(calls[0].body.text).startsWith('[dsh]'), 'webhook payload 缺少 text')
|
|
414
414
|
} finally { globalThis.fetch = original }
|
|
415
415
|
})
|
|
416
|
+
|
|
417
|
+
// --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
|
|
418
|
+
|
|
419
|
+
test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', async () => {
|
|
420
|
+
const mod = await import('../src/index.js')
|
|
421
|
+
assert.ok(mod.Config, 'Config 导出必须在场')
|
|
422
|
+
const resolved = mod.Config['~standard'].validate({})
|
|
423
|
+
assert.equal(resolved.issues, undefined)
|
|
424
|
+
const section = resolved.value.get()
|
|
425
|
+
assert.equal(section.webhookUrl, '')
|
|
426
|
+
assert.equal(section.rootsOnly, true)
|
|
427
|
+
assert.equal(section.imEnabled, true)
|
|
428
|
+
assert.ok(section.enabled && typeof section.enabled === 'object')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// 0.1.7 形态装配:settings 无 register/get(宿主已移除),config 为整节单 ref 桩,
|
|
432
|
+
// configEditor 桩经深合并后原样写回
|
|
433
|
+
function makeRc17Ctx({ configStore = {}, configEditorAvailable = true } = {}) {
|
|
434
|
+
const routes = new Map()
|
|
435
|
+
const editCalls = []
|
|
436
|
+
const config = { get: () => ({ ...configStore }) }
|
|
437
|
+
const settingsService = {}
|
|
438
|
+
const configEditor = {
|
|
439
|
+
async edit(entry, change) {
|
|
440
|
+
editCalls.push(entry)
|
|
441
|
+
const merge = (under, over) => {
|
|
442
|
+
const out = { ...under }
|
|
443
|
+
for (const key of Object.keys(over)) {
|
|
444
|
+
const next = over[key]
|
|
445
|
+
const underValue = out[key]
|
|
446
|
+
const bothPlain = (next !== null && typeof next === 'object' && !Array.isArray(next))
|
|
447
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
448
|
+
out[key] = bothPlain ? merge(underValue, next) : next
|
|
449
|
+
}
|
|
450
|
+
return out
|
|
451
|
+
}
|
|
452
|
+
const next = merge({ ...configStore }, change({}, {}))
|
|
453
|
+
for (const key of Object.keys(next)) configStore[key] = next[key]
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
const configureCalls = []
|
|
457
|
+
const ctx = {
|
|
458
|
+
on() {},
|
|
459
|
+
fiber: { entry: { options: { id: 'turn-notify', name: '@mzzsfy/dsh-turn-notify' } } },
|
|
460
|
+
get(key) {
|
|
461
|
+
if (key === 'settings') return settingsService
|
|
462
|
+
if (key === 'configEditor') return configEditorAvailable ? configEditor : undefined
|
|
463
|
+
return undefined
|
|
464
|
+
},
|
|
465
|
+
inject(deps, fn) {
|
|
466
|
+
if (deps[0] === 'settings') {
|
|
467
|
+
fn({
|
|
468
|
+
settings: settingsService,
|
|
469
|
+
effect(stubEffect) {
|
|
470
|
+
const disposer = stubEffect()
|
|
471
|
+
if (typeof disposer === 'function') configureCalls.push(disposer)
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
effect(thunk) { thunk() },
|
|
477
|
+
webServer: { register(route) { routes.set(route.path, route.handler) } },
|
|
478
|
+
}
|
|
479
|
+
return { ctx, routes, config, configStore, editCalls, configureCalls }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
test('0.1.7 形态:config GET 读 config ref 节值,POST 经 configEditor.edit 深合并', async () => {
|
|
483
|
+
// Given config ref 携带用户值 + configEditor 桩
|
|
484
|
+
const { ctx, routes, config, configStore, editCalls } = makeRc17Ctx({
|
|
485
|
+
configStore: { enabled: { completed: false }, webhookUrl: 'https://hook.example' },
|
|
486
|
+
})
|
|
487
|
+
apply(ctx, config)
|
|
488
|
+
// When GET config
|
|
489
|
+
const read = makeRes()
|
|
490
|
+
await routes.get('/api/turn-notify/config')(makeReq('GET'), read)
|
|
491
|
+
// Then 返回 config 节值
|
|
492
|
+
assert.equal(read.body.enabled.completed, false)
|
|
493
|
+
assert.equal(read.body.webhookUrl, 'https://hook.example')
|
|
494
|
+
// When POST 嵌套补丁(仅关一个分类 + 改时长)
|
|
495
|
+
const saved = makeRes()
|
|
496
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { enabled: { error: false }, minTurnDurationMs: 1234 }, JSON_HEADERS), saved)
|
|
497
|
+
// Then 深合并落盘:未提交的兄弟分类保留,新键写入
|
|
498
|
+
assert.equal(saved.status, 200)
|
|
499
|
+
assert.equal(editCalls.length, 1)
|
|
500
|
+
assert.equal(editCalls[0].options.id, 'turn-notify')
|
|
501
|
+
assert.equal(configStore.enabled.completed, false, '既有分类开关保留')
|
|
502
|
+
assert.equal(configStore.enabled.error, false, '新分类开关写入')
|
|
503
|
+
assert.equal(configStore.minTurnDurationMs, 1234)
|
|
504
|
+
// When 重读
|
|
505
|
+
const reread = makeRes()
|
|
506
|
+
await routes.get('/api/turn-notify/config')(makeReq('GET'), reread)
|
|
507
|
+
// Then 回显合并后节值
|
|
508
|
+
assert.equal(reread.body.enabled.error, false)
|
|
509
|
+
assert.equal(reread.body.enabled.completed, false)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('0.1.7 形态:soundMapping 单分类写经 configEditor.edit 深合并,null 清除保留', async () => {
|
|
513
|
+
// Given 既有映射两个分类
|
|
514
|
+
const { ctx, routes, config, configStore } = makeRc17Ctx({
|
|
515
|
+
configStore: { soundMapping: { completed: 'ding', 'max-tokens': 'buzz' } },
|
|
516
|
+
})
|
|
517
|
+
apply(ctx, config)
|
|
518
|
+
// When POST 单分类改映射
|
|
519
|
+
const saved = makeRes()
|
|
520
|
+
await routes.get('/api/turn-notify/mapping')(makeReq('POST', { category: 'completed', id: 'bell' }, JSON_HEADERS), saved)
|
|
521
|
+
// Then 兄弟分类保留,目标分类更新
|
|
522
|
+
assert.equal(saved.status, 200)
|
|
523
|
+
assert.equal(configStore.soundMapping.completed, 'bell')
|
|
524
|
+
assert.equal(configStore.soundMapping['max-tokens'], 'buzz')
|
|
525
|
+
// When POST 空 id(清除语义,null 落盘)
|
|
526
|
+
await routes.get('/api/turn-notify/mapping')(makeReq('POST', { category: 'max-tokens', id: '' }, JSON_HEADERS), makeRes())
|
|
527
|
+
// Then null 原样落盘(读侧过滤)
|
|
528
|
+
assert.equal(configStore.soundMapping['max-tokens'], null)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test('0.1.7 形态:configEditor 缺失即 500 拒写,不崩溃', async () => {
|
|
532
|
+
// Given configEditor 服务缺席
|
|
533
|
+
const { ctx, routes, config } = makeRc17Ctx({ configEditorAvailable: false })
|
|
534
|
+
apply(ctx, config)
|
|
535
|
+
// When POST 写配置
|
|
536
|
+
const denied = makeRes()
|
|
537
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { rootsOnly: false }, JSON_HEADERS), denied)
|
|
538
|
+
// Then 400 错误响应(sendError 归一,settings 服务不可用非 fs 错误),进程存活
|
|
539
|
+
assert.equal(denied.status, 400)
|
|
540
|
+
assert.ok(denied.body.error)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('0.1.7 形态:settings.configure 在场即关闭原生自动页,register 缺失不抛错', () => {
|
|
544
|
+
// Given settingsForms 带 configure 无 register
|
|
545
|
+
const configureCalls = []
|
|
546
|
+
const { ctx, config } = makeRc17Ctx()
|
|
547
|
+
ctx.get('settings').configure = (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} }
|
|
548
|
+
// When apply
|
|
549
|
+
apply(ctx, config)
|
|
550
|
+
// Then 关闭自动页且 owner 为本插件 fiber
|
|
551
|
+
assert.equal(configureCalls.length, 1)
|
|
552
|
+
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
553
|
+
})
|