@mzzsfy/dsh-turn-notify 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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。
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.0",
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,6 +98,7 @@ 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
 
@@ -311,6 +312,8 @@ export function apply(ctx) {
311
312
  function deliverIm(unit, settings) {
312
313
  const dshIm = ctx.get('dshIm')
313
314
  if (dshIm === undefined) return
315
+ // 总开关关闭:目标绑定保留,仅不投递
316
+ if (settings.imEnabled === false) return
314
317
  for (const { botId, targetId } of normalizeImTargets(settings.imTargets)) {
315
318
  void Promise.resolve().then(() => dshIm.send(botId, targetId, unit.text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })).catch(() => {})
316
319
  }
@@ -757,6 +760,7 @@ export function apply(ctx) {
757
760
  sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
758
761
  return
759
762
  }
763
+ // 点火测试与开关无关:显式验证通道连通,不受投递总开关压制
760
764
  // 逐目标结算,真实结果随响应返回,与 test-webhook 的不谎报原则一致
761
765
  const results = await Promise.all(targets.map(async ({ botId, targetId }) => {
762
766
  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)