@mzzsfy/dsh-turn-notify 0.11.1 → 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 CHANGED
@@ -91,7 +91,7 @@ turn-notify:
91
91
 
92
92
  ## IM 推送
93
93
 
94
- 安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot。触发逻辑与 webhook 一致,fire-and-forget 不重试;bot 离线时该次通知弃置不补发。
94
+ 安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot。IM 卡顶部有总开关,关闭后不向已配目标投递、目标绑定原样保留,临时停用不必逐个移除目标,再开即恢复。触发逻辑与 webhook 一致,fire-and-forget 不重试;bot 离线时该次通知弃置不补发。测试页的"测试 IM 通知"为显式点火,不受总开关影响。
95
95
 
96
96
  ## 会话行高亮
97
97
 
@@ -114,7 +114,7 @@ turn-notify:
114
114
  | 通知 | webhook 地址、最短回合时长、子代理过滤、宿主通知(总开关与回退)、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
115
115
  | 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
116
116
  | 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
117
- | IM | 绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
117
+ | IM | IM 总开关(关闭保留目标)、绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
118
118
  | 测试 | 声音/页内/系统/宿主/webhook/IM 逐通道点火,回执即真实结果 |
119
119
 
120
120
  ## settings.yaml 参考
@@ -143,6 +143,7 @@ turn-notify:
143
143
  imTargets: # dsh-im 投递目标,空数组禁用
144
144
  - botId: wx_xxx
145
145
  targetId: owner
146
+ imEnabled: true # IM 推送总开关:关闭后不投递,目标绑定保留
146
147
  ```
147
148
 
148
149
  音量、聚焦静默、分类静音等本机偏好存浏览器 localStorage,不经 yaml。
@@ -191,4 +192,4 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
191
192
 
192
193
  ## dsh 版本兼容
193
194
 
194
- 三版本全部通过:通知开关与播报形态、激活 live。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook、IM 与宿主桌面通知;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.11.1",
4
+ "version": "0.12.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1071,6 +1071,7 @@ window.__ModuleLoader__.load({
1071
1071
  folderRunningEnabled: true,
1072
1072
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
1073
1073
  imTargets: [],
1074
+ imEnabled: true,
1074
1075
  }
1075
1076
 
1076
1077
  const CSS = [
@@ -1616,6 +1617,18 @@ window.__ModuleLoader__.load({
1616
1617
  void persistImTargets(removeImTargetFromList(config.imTargets, item.botId, item.targetId))
1617
1618
  }
1618
1619
 
1620
+ // IM 总开关:目标绑定保留,关闭仅停投递;乐观回填,失败回滚
1621
+ function toggleImEnabled(checked) {
1622
+ setConfig((prev) => ({ ...prev, imEnabled: checked }))
1623
+ api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ imEnabled: checked }) })
1624
+ .then((res) => setConfig({ ...DEFAULT_CONFIG, ...res }))
1625
+ .catch((error) => {
1626
+ // 回滚到请求发出前的取值:两次异步 setConfig 闭包各自读到已更新的 prev
1627
+ setConfig((prev) => ({ ...prev, imEnabled: !prev.imEnabled }))
1628
+ patch('IM 开关切换失败:' + (error && error.message ? error.message : String(error)), 'error')
1629
+ })
1630
+ }
1631
+
1619
1632
  // 取消注册:移除该 bot 全部目标;bot 在 dsh-im 已删除时借此清理残留绑定
1620
1633
  function unregisterImBot(botId) {
1621
1634
  void persistImTargets(
@@ -2002,6 +2015,14 @@ window.__ModuleLoader__.load({
2002
2015
  h('span', { className: 'tn-card__title' }, 'IM 投递(dsh-im)'),
2003
2016
  h('span', { className: 'tn-card__sub' }, '勾选目标即自动保存;支持绑定多个 bot,点 bot 名加载其目录,× 取消注册'),
2004
2017
  ),
2018
+ field('IM 开关', [
2019
+ h('label', { className: 'tn-meta tn-switch', title: '关闭后不再向已配目标投递 IM 通知,目标绑定保留;再次开启即恢复推送' },
2020
+ ...switchToggle({
2021
+ checked: config.imEnabled,
2022
+ onChange: (e) => toggleImEnabled(e.target.checked),
2023
+ }),
2024
+ ' 启用 IM 推送'),
2025
+ ], '关闭时通知不推送 IM,已勾选的目标与 bot 绑定原样保留,无需移除目标'),
2005
2026
  field('Bot ID', [
2006
2027
  h('input', {
2007
2028
  className: 'tn-input tn-fill', type: 'text',
package/src/core.mjs CHANGED
@@ -635,7 +635,7 @@ export function imBoundBotIds(list) {
635
635
  // 返回归一化后的补丁。
636
636
  export function validateConfigPatch(patch) {
637
637
  if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
638
- const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
638
+ const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'imEnabled', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
639
639
  for (const key of Object.keys(patch)) {
640
640
  if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
641
641
  }
@@ -675,6 +675,10 @@ export function validateConfigPatch(patch) {
675
675
  }
676
676
  next.imTargets = targets
677
677
  }
678
+ if ('imEnabled' in patch) {
679
+ if (typeof patch.imEnabled !== 'boolean') return { ok: false, reason: 'imEnabled 须为布尔' }
680
+ next.imEnabled = patch.imEnabled
681
+ }
678
682
  if ('minTurnDurationMs' in patch) {
679
683
  const ms = patch.minTurnDurationMs
680
684
  if (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 0) return { ok: false, reason: 'minTurnDurationMs 须为非负整数' }
@@ -769,6 +773,8 @@ export function resolvedConfig(settings) {
769
773
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
770
774
  soundMapping,
771
775
  imTargets: normalizeImTargets(source.imTargets),
776
+ // 开关缺省回开:存量配置升级行为不变
777
+ imEnabled: source.imEnabled !== false,
772
778
  kindRoutes: routesVerdict.ok ? routesVerdict.routes : {},
773
779
  }
774
780
  }
@@ -786,6 +792,7 @@ export function publicConfig(settings) {
786
792
  enabled: resolved.enabled,
787
793
  soundMapping: resolved.soundMapping,
788
794
  imTargets: resolved.imTargets,
795
+ imEnabled: resolved.imEnabled,
789
796
  kindRoutes: resolved.kindRoutes,
790
797
  webhookUrl: resolved.webhookUrl,
791
798
  }
package/src/index.js CHANGED
@@ -98,13 +98,56 @@ const SETTINGS_SCHEMA = z.object({
98
98
  enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
99
99
  soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
100
100
  imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
101
+ imEnabled: z.boolean().default(true).description('IM 推送总开关:关闭后不向已配目标投递,目标绑定保留'),
101
102
  kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im/host),未配置的分类全通道放行'),
102
103
  })
103
104
 
104
105
  // 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
105
- const readSettings = (ctx) => {
106
+ const readSettings = (ctx, config) => {
106
107
  const settings = ctx.get('settings')
107
- return resolvedConfig(settings ? settings.get(NAMESPACE) : undefined)
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
108
151
  }
109
152
 
110
153
  function sendJson(res, status, payload) {
@@ -186,7 +229,7 @@ async function readSoundIndex() {
186
229
  const writeSoundIndex = (index) => writeFile(soundIndexPath, JSON.stringify(index))
187
230
 
188
231
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
189
- export function apply(ctx) {
232
+ export function apply(ctx, config) {
190
233
  const projection = createProjection({})
191
234
  // 打开回合:按会话 id 记回合序号与起始时间,回合结束即清;序号严格递增,
192
235
  // 供子代理注册归属判定,同毫秒内多回合不受时钟精度歧义影响
@@ -240,7 +283,7 @@ export function apply(ctx) {
240
283
 
241
284
  // durationMs 与 wakeTurn / awaitingChildren 由调用方在清理状态前读取后传入,防先删后读
242
285
  function notifyUnit({ category, kind, reasonKind, session, durationMs = null, wakeTurn = false, awaitingChildren = false }) {
243
- const settings = readSettings(ctx)
286
+ const settings = readSettings(ctx, config)
244
287
  const header = session.header || {}
245
288
  const sessionId = String(session.id ?? '')
246
289
  if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
@@ -311,6 +354,8 @@ export function apply(ctx) {
311
354
  function deliverIm(unit, settings) {
312
355
  const dshIm = ctx.get('dshIm')
313
356
  if (dshIm === undefined) return
357
+ // 总开关关闭:目标绑定保留,仅不投递
358
+ if (settings.imEnabled === false) return
314
359
  for (const { botId, targetId } of normalizeImTargets(settings.imTargets)) {
315
360
  void Promise.resolve().then(() => dshIm.send(botId, targetId, unit.text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })).catch(() => {})
316
361
  }
@@ -416,6 +461,14 @@ export function apply(ctx) {
416
461
  ))
417
462
 
418
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
+ }
419
472
  settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
420
473
  })
421
474
 
@@ -468,7 +521,7 @@ export function apply(ctx) {
468
521
  if (query.has('cursor') && projection.version() === cursor) {
469
522
  await projection.wait(cursor, LONG_POLL_WAIT_MS)
470
523
  }
471
- const settings = readSettings(ctx)
524
+ const settings = readSettings(ctx, config)
472
525
  sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
473
526
  } finally {
474
527
  if (releaseBrowserPoll !== null) releaseBrowserPoll()
@@ -599,15 +652,14 @@ export function apply(ctx) {
599
652
  await writeSoundIndex(index)
600
653
  }
601
654
  // 被引用即回落:深合并语义下引用置 null 清除,resolveSound 兜底回内置默认
602
- const settings = ctx.get('settings')
603
- if (settings) {
604
- const current = readSettings(ctx)
655
+ if (ctx.get('settings')) {
656
+ const current = readSettings(ctx, config)
605
657
  const patch = {}
606
658
  for (const key of Object.keys(current.soundMapping)) {
607
659
  if (current.soundMapping[key] === id) patch[key] = null
608
660
  }
609
661
  if (Object.keys(patch).length > 0) {
610
- await settings.update(NAMESPACE, { soundMapping: patch })
662
+ await persistPatch(ctx, { soundMapping: patch })
611
663
  projection.bump()
612
664
  }
613
665
  }
@@ -633,15 +685,15 @@ export function apply(ctx) {
633
685
  return
634
686
  }
635
687
  // 校验到写回整段入互斥域:防与 DELETE 清引用并发时把已删音效的 id 写回映射;
636
- // settings.update 深合并无法删键,清除以 null 表达,读侧 resolvedConfig 过滤
688
+ // 深合并无法删键,清除以 null 表达,读侧 resolvedConfig 过滤
637
689
  await serializedSoundWrite(async () => {
638
690
  if (!validateMappingId(id, (await listSounds()).map((sound) => sound.id))) {
639
691
  sendJson(res, 400, { error: '未知音效: ' + id })
640
692
  return
641
693
  }
642
- await settings.update(NAMESPACE, { soundMapping: { [category]: id.length === 0 ? null : id } })
694
+ await persistPatch(ctx, { soundMapping: { [category]: id.length === 0 ? null : id } })
643
695
  projection.bump()
644
- sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx).soundMapping })
696
+ sendJson(res, 200, { ok: true, soundMapping: readSettings(ctx, config).soundMapping })
645
697
  })
646
698
  }),
647
699
  }), 'turn-notify mapping route')
@@ -654,7 +706,7 @@ export function apply(ctx) {
654
706
  handler: async (req, res) => {
655
707
  // imAvailable 不进 core 纯函数,config 响应处合流;实时判定,无装载时序假设
656
708
  if (req.method === 'GET') {
657
- sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
709
+ sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
658
710
  return
659
711
  }
660
712
  if (req.method !== 'POST') {
@@ -668,14 +720,9 @@ export function apply(ctx) {
668
720
  sendJson(res, 400, { error: verdict.reason })
669
721
  return
670
722
  }
671
- const settings = ctx.get('settings')
672
- if (!settings) {
673
- sendJson(res, 500, { error: 'settings 服务不可用' })
674
- return
675
- }
676
- await settings.update(NAMESPACE, verdict.patch)
723
+ await persistPatch(ctx, verdict.patch)
677
724
  projection.bump()
678
- sendJson(res, 200, { ...publicConfig(readSettings(ctx)), imAvailable: imReady() })
725
+ sendJson(res, 200, { ...publicConfig(readSettings(ctx, config)), imAvailable: imReady() })
679
726
  } catch (error) {
680
727
  sendError(res, error)
681
728
  }
@@ -688,7 +735,7 @@ export function apply(ctx) {
688
735
  path: '/api/turn-notify/test-webhook',
689
736
  handler: route('POST', { crossOrigin: true }, async (req, res) => {
690
737
  // 等待投递完成,真实结果随响应返回,测试按钮不再谎报
691
- const result = await sendWebhook({ url: readSettings(ctx).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
738
+ const result = await sendWebhook({ url: readSettings(ctx, config).webhookUrl, payload: buildWebhookPayload(buildTestUnit()) })
692
739
  sendJson(res, 200, result)
693
740
  }),
694
741
  }), 'turn-notify test-webhook route')
@@ -752,11 +799,12 @@ export function apply(ctx) {
752
799
  sendJson(res, 200, { ok: false, detail: 'dsh-im 未安装' })
753
800
  return
754
801
  }
755
- const targets = normalizeImTargets(readSettings(ctx).imTargets)
802
+ const targets = normalizeImTargets(readSettings(ctx, config).imTargets)
756
803
  if (targets.length === 0) {
757
804
  sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
758
805
  return
759
806
  }
807
+ // 点火测试与开关无关:显式验证通道连通,不受投递总开关压制
760
808
  // 逐目标结算,真实结果随响应返回,与 test-webhook 的不谎报原则一致
761
809
  const results = await Promise.all(targets.map(async ({ botId, targetId }) => {
762
810
  try {
@@ -646,6 +646,10 @@ test('配置补丁校验:非法输入逐类拒绝', () => {
646
646
  assert.equal(validateConfigPatch({ minTurnDurationMs: 'fast' }).ok, false)
647
647
  assert.equal(validateConfigPatch({ rootsOnly: 'yes' }).ok, false)
648
648
  assert.equal(validateConfigPatch({ suppressSubagentWake: 'yes' }).ok, false)
649
+ // imEnabled:布尔放行,非布尔拒绝
650
+ assert.deepEqual(validateConfigPatch({ imEnabled: false }), { ok: true, patch: { imEnabled: false } })
651
+ assert.equal(validateConfigPatch({ imEnabled: 'yes' }).ok, false)
652
+ assert.equal(validateConfigPatch({ imEnabled: 1 }).ok, false)
649
653
  assert.equal(validateConfigPatch({ enabled: { unknown: true } }).ok, false)
650
654
  assert.equal(validateConfigPatch({ enabled: { completed: 'no' } }).ok, false)
651
655
  assert.equal(validateConfigPatch({ enabled: [true] }).ok, false)
@@ -665,6 +669,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
665
669
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
666
670
  soundMapping: { completed: 'snd-1' },
667
671
  imTargets: [],
672
+ imEnabled: true,
668
673
  kindRoutes: { completed: ['webhook'] },
669
674
  },
670
675
  )
@@ -679,10 +684,14 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
679
684
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
680
685
  soundMapping: {},
681
686
  imTargets: [],
687
+ imEnabled: true,
682
688
  kindRoutes: {},
683
689
  })
684
690
  assert.equal(resolvedConfig({ minTurnDurationMs: Number.NaN }).minTurnDurationMs, MIN_TURN_MS)
685
691
  assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
692
+ // imEnabled:显式 false 生效,缺省或类型异常回开(升级兼容)
693
+ assert.equal(resolvedConfig({ imEnabled: false }).imEnabled, false)
694
+ assert.equal(resolvedConfig({ imEnabled: 'no' }).imEnabled, true)
686
695
  })
687
696
 
688
697
  test('面板可见配置:webhookUrl 原文回显', () => {
@@ -698,6 +707,7 @@ test('面板可见配置:webhookUrl 原文回显', () => {
698
707
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
699
708
  soundMapping: { completed: 'snd-1' },
700
709
  imTargets: [],
710
+ imEnabled: true,
701
711
  kindRoutes: { ask: ['im'] },
702
712
  webhookUrl: 'https://hook.example/service/xxx',
703
713
  },
@@ -377,6 +377,27 @@ test('Given dshIm 在场且已配多目标 When 回合完成 Then 逐目标投
377
377
  assert.ok(sends.every((call) => String(call.text).startsWith('[dsh]')), 'IM 文本应与通知单元一致')
378
378
  })
379
379
 
380
+ test('Given 已配目标且 imEnabled=false When 回合完成 Then 不投递且目标保留', async () => {
381
+ const sends = []
382
+ const { ctx, routes, handlers } = makeCtx({ dshIm: makeImDshIm(sends) })
383
+ apply(ctx)
384
+ await disableDurationFilter(routes)
385
+ await configureImTargets(routes, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'group' }])
386
+ const offRes = makeRes()
387
+ await routes.get('/api/turn-notify/config')(makeReq('POST', { imEnabled: false }, JSON_HEADERS), offRes)
388
+ assert.equal(offRes.status, 200)
389
+ assert.equal(offRes.body.imEnabled, false)
390
+ assert.equal(offRes.body.imTargets.length, 2, '开关关闭不应清空目标')
391
+ const onEvent = handlers.get('session/event')
392
+ onEvent(MAIN, { type: 'turn/start' })
393
+ onEvent(MAIN, turnEnd('completed'))
394
+ await flushMicrotasks()
395
+ assert.equal(sends.length, 0, 'imEnabled=false 时不投递')
396
+ // 投影照常:开关只压制 IM 通道
397
+ const units = await projectionUnits(routes)
398
+ assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
399
+ })
400
+
380
401
  test('Given send 拒绝 When 回合完成 Then 无未处理拒绝且投影照常', async () => {
381
402
  const rejections = []
382
403
  const onUnhandled = (reason) => rejections.push(reason)
@@ -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
+ })