@mzzsfy/dsh-turn-notify 0.8.0 → 0.9.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
@@ -78,7 +78,7 @@ turn-notify:
78
78
 
79
79
  ## webhook 推送
80
80
 
81
- 在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。凭据只写不回显,测试按钮返回真实投递结果。
81
+ 在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。URL 原文随配置响应回显、保存即提交输入框内容(清空并保存即禁用),测试按钮返回真实投递结果。
82
82
 
83
83
  ## IM 推送
84
84
 
@@ -106,7 +106,7 @@ turn-notify:
106
106
 
107
107
  ## settings.yaml 参考
108
108
 
109
- 面板与 settings.yaml 读写同一命名空间,两边改动互通、保存即生效。webhookUrl 属凭据,面板只写不回显,yaml 直改仍可。
109
+ 面板与 settings.yaml 读写同一命名空间,两边改动互通、保存即生效。webhookUrl 随面板配置响应回显,yaml 直改同样可见。
110
110
 
111
111
  ```yaml
112
112
  turn-notify:
@@ -155,7 +155,7 @@ turn-notify:
155
155
  ## 安全
156
156
 
157
157
  - 配置写入类接口(config / mapping / upload / 音效改名与删除 / 测试)带同源守卫:Origin 与 Host 不符即 403,JSON 写入另校验 content-type,阻断跨站页面 drive-by 改写配置。
158
- - webhookUrl 标记为 secret,任何接口不回传原文。
158
+ - webhookUrl 在 settings 层标记为 secret(导出与日志脱敏);面板配置接口原文回显,本机同源可读写。
159
159
  - 音效上传双重校验扩展名与真实音频内容;音效读取接口拒绝音频扩展名以外的文件。
160
160
  - 已知边界:同源守卫不防 DNS rebinding(Origin 与 Host 相等即放行)。该暴露面属 host webserver 全部 /api 路由的存量问题,应在 host 层统一解决而非逐插件补丁。
161
161
 
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.8.0",
4
+ "version": "0.9.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  id: '@mzzsfy/dsh-turn-notify',
9
9
  factory(require) {
10
10
  const React = require('react')
11
- const { useState, useEffect } = React
11
+ const { useState, useEffect, useRef } = React
12
12
 
13
13
  // 通知出口:公共依赖 @mzzsfy/dsh-toast,可选消费——共享依赖包的模块表注入
14
14
  // 全仓收敛到唯一权威消费方(session-manager),本插件不再代挂占位条目;
@@ -827,13 +827,28 @@ window.__ModuleLoader__.load({
827
827
 
828
828
  // 宿主重渲染会重建行节点抹掉高亮类,观察器在同一帧内补齐;
829
829
  // applySessionHighlights 幂等(类齐不写 DOM),观察器链自然收敛;
830
- // 令牌承载 observer:HMR 重建闭包后先断开旧代再挂新代,旧闭包不滞留
830
+ // 令牌承载 observer 与 pending 帧:HMR 重建闭包后先断开旧代 observer、
831
+ // cancel 旧代 pending 帧(帧回调持有旧代高亮 Map,不取消会写幽灵高亮),
832
+ // 旧闭包不滞留;rAF 合批:流式期间 DOM 变更批远高于帧率,同帧多批合并
833
+ // 为一次重应用(同帧补齐语义不变),无高亮条目时不排帧
831
834
  const KEY_HL_OBSERVER = 'turn-notify:hl-observer'
835
+ const KEY_HL_FRAME = 'turn-notify:hl-frame'
832
836
  function ensureHighlightObserver() {
833
837
  if (typeof document === 'undefined' || typeof document.body === 'undefined' || typeof MutationObserver === 'undefined') return
834
838
  if (window[KEY_HL_OBSERVER] !== undefined && typeof window[KEY_HL_OBSERVER].disconnect === 'function') window[KEY_HL_OBSERVER].disconnect()
839
+ if (typeof window[KEY_HL_FRAME] === 'number') {
840
+ cancelAnimationFrame(window[KEY_HL_FRAME])
841
+ }
842
+ // 哨兵 0 = 无在途帧(规范 rAF 句柄自 1 起,cancel 0 为 no-op);新代显式归零,
843
+ // 防 undefined 初始态使在途判定恒真、帧永不排程
844
+ window[KEY_HL_FRAME] = 0
835
845
  const observer = new MutationObserver(() => {
836
- if (sessionHighlightEnabled && sessionHighlights.size > 0) applySessionHighlights()
846
+ if (!sessionHighlightEnabled || sessionHighlights.size === 0) return
847
+ if (window[KEY_HL_FRAME] !== 0) return
848
+ window[KEY_HL_FRAME] = requestAnimationFrame(() => {
849
+ window[KEY_HL_FRAME] = 0
850
+ if (sessionHighlightEnabled && sessionHighlights.size > 0) applySessionHighlights()
851
+ })
837
852
  })
838
853
  observer.observe(document.body, { childList: true, subtree: true })
839
854
  window[KEY_HL_OBSERVER] = observer
@@ -994,10 +1009,10 @@ window.__ModuleLoader__.load({
994
1009
 
995
1010
  const PERMISSION_LABELS = { granted: '已授权', denied: '已拒绝', default: '未授权' }
996
1011
 
997
- // 面板表单占位:GET config 返回前展示;webhookUrl 凭据不出主机,面板只见是否已配置;
1012
+ // 面板表单占位:GET config 返回前展示;webhookUrl 原文随响应回显,表单所见即所存;
998
1013
  // imAvailable 缺省为假,加载响应后 dsh-im 在场才渲染 IM 投递卡
999
1014
  const DEFAULT_CONFIG = {
1000
- webhookConfigured: false,
1015
+ webhookUrl: '',
1001
1016
  minTurnDurationMs: 5 * 1000,
1002
1017
  rootsOnly: true,
1003
1018
  suppressSubagentWake: true,
@@ -1181,6 +1196,7 @@ window.__ModuleLoader__.load({
1181
1196
  .then((res) => {
1182
1197
  setConfig({ ...DEFAULT_CONFIG, ...res })
1183
1198
  setMappingState(res.soundMapping || {})
1199
+ setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
1184
1200
  setConfigLoaded(true)
1185
1201
  })
1186
1202
  .catch(() => {})
@@ -1351,14 +1367,12 @@ window.__ModuleLoader__.load({
1351
1367
  : String(config.minTurnDurationMs)
1352
1368
  if (raw.length === 0) throw new Error('最短回合时长不能为空')
1353
1369
  const trimmedUrl = urlDraft.trim()
1354
- // imTargets 由勾选单独即时保存,不随此入口提交;会话高亮为本机开关,不进 host 配置
1355
- const patchBody = { minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
1356
- // 只写语义:输入留空即保持现有 webhook 不变
1357
- if (trimmedUrl.length > 0) patchBody.webhookUrl = trimmedUrl
1370
+ // 所见即所存:输入框即配置值,清空并保存即禁用 webhook 通道
1371
+ const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
1358
1372
  const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify(patchBody) })
1359
1373
  setConfig({ ...DEFAULT_CONFIG, ...res })
1360
1374
  if (res.soundMapping) setMappingState(res.soundMapping)
1361
- setUrlDraft('')
1375
+ setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
1362
1376
  patch('配置已保存,立即生效')
1363
1377
  } catch (error) {
1364
1378
  patch('保存失败:' + (error && error.message ? error.message : String(error)), 'error')
@@ -1371,7 +1385,7 @@ window.__ModuleLoader__.load({
1371
1385
  const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ webhookUrl: '' }) })
1372
1386
  setConfig({ ...DEFAULT_CONFIG, ...res })
1373
1387
  if (res.soundMapping) setMappingState(res.soundMapping)
1374
- setUrlDraft('')
1388
+ setUrlDraft(typeof res.webhookUrl === 'string' ? res.webhookUrl : '')
1375
1389
  patch('webhook 已清除')
1376
1390
  } catch (error) {
1377
1391
  patch('清除失败:' + (error && error.message ? error.message : String(error)), 'error')
@@ -1452,8 +1466,9 @@ window.__ModuleLoader__.load({
1452
1466
  }
1453
1467
 
1454
1468
  async function testWebhook() {
1455
- if (urlDraft.trim().length > 0) {
1456
- patch('表单中的新 webhook URL 尚未保存,本次测试的是已保存配置;请先保存再测试', 'error')
1469
+ // 草稿与已保存值不同即存在未保存改动,测试的只能是已保存配置
1470
+ if (urlDraft.trim() !== String(config.webhookUrl || '').trim()) {
1471
+ patch('表单中的 webhook URL 尚未保存,本次测试的是已保存配置;请先保存再测试', 'error')
1457
1472
  return
1458
1473
  }
1459
1474
  try {
@@ -1484,14 +1499,16 @@ window.__ModuleLoader__.load({
1484
1499
  // 已绑 bot chips 由 imBoundBotIds 去重(见 LOGIC 段同源函数)
1485
1500
  const imBoundBots = imBoundBotIds(config.imTargets)
1486
1501
 
1487
- // 勾选即存:与分类开关同模式,列表整体替换,连续操作以最新一次请求为准
1488
- let imPersistSeq = 0
1502
+ // 勾选即存:与分类开关同模式,列表整体替换,连续操作以最新一次请求为准;
1503
+ // seq 守卫须跨渲染存活(useRef),组件体内 let 声明每次渲染重置使守卫失效
1504
+ const imPersistSeqRef = useRef(0)
1489
1505
  async function persistImTargets(next, okMessage) {
1490
- const seq = ++imPersistSeq
1506
+ imPersistSeqRef.current += 1
1507
+ const seq = imPersistSeqRef.current
1491
1508
  setConfig({ ...config, imTargets: next })
1492
1509
  try {
1493
1510
  const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ imTargets: next }) })
1494
- if (seq === imPersistSeq) {
1511
+ if (seq === imPersistSeqRef.current) {
1495
1512
  setConfig({ ...DEFAULT_CONFIG, ...res })
1496
1513
  if (res.soundMapping) setMappingState(res.soundMapping)
1497
1514
  if (okMessage !== undefined) patch(okMessage)
@@ -1615,15 +1632,15 @@ window.__ModuleLoader__.load({
1615
1632
  field('webhook', [
1616
1633
  h('input', {
1617
1634
  className: 'tn-input tn-fill', type: 'text',
1618
- title: '通知由 host 直接 POST 到该地址,标签页全关也送达;Slack 兼容 JSON 格式,超时 10 秒不重试,凭据不回显',
1619
- placeholder: config.webhookConfigured ? '已配置(输入新 URL 替换,留空保持不变)' : 'Slack-compatible URL,留空禁用',
1635
+ title: '通知由 host 直接 POST 到该地址,标签页全关也送达;Slack 兼容 JSON 格式,超时 10 秒不重试',
1636
+ placeholder: 'Slack-compatible URL,留空禁用',
1620
1637
  value: urlDraft,
1621
1638
  onChange: (e) => setUrlDraft(e.target.value),
1622
1639
  }),
1623
- config.webhookConfigured
1640
+ String(config.webhookUrl || '').trim().length > 0
1624
1641
  ? h('button', { className: 'tn-btn tn-btn--danger', disabled: busy, title: '清除已配置的 webhook,清除后该通道禁用', onClick: () => void clearWebhook() }, '清除')
1625
1642
  : null,
1626
- ], 'URL 只写不回显'),
1643
+ ], '保存即提交输入框内容,清空并保存即禁用;改动后先保存,测试按钮只测已保存配置'),
1627
1644
  field('最短回合时长', [
1628
1645
  h('input', {
1629
1646
  className: 'tn-input', type: 'number', min: 0, step: 500, style: { width: '90px' },
package/src/core.mjs CHANGED
@@ -664,7 +664,7 @@ export function resolvedConfig(settings) {
664
664
  }
665
665
  }
666
666
 
667
- // 面板可见配置:webhookUrl 属凭据不出主机,仅回是否已配置。
667
+ // 面板可见配置:webhookUrl 原文随响应回显,面板所见即所存。
668
668
  export function publicConfig(settings) {
669
669
  const resolved = resolvedConfig(settings)
670
670
  return {
@@ -675,6 +675,6 @@ export function publicConfig(settings) {
675
675
  soundMapping: resolved.soundMapping,
676
676
  imTargets: resolved.imTargets,
677
677
  kindRoutes: resolved.kindRoutes,
678
- webhookConfigured: resolved.webhookUrl.trim().length > 0,
678
+ webhookUrl: resolved.webhookUrl,
679
679
  }
680
- }
680
+ }
@@ -679,7 +679,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
679
679
  assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
680
680
  })
681
681
 
682
- test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
682
+ test('面板可见配置:webhookUrl 原文回显', () => {
683
683
  assert.deepEqual(
684
684
  publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { ask: ['im'] } }),
685
685
  {
@@ -690,12 +690,13 @@ test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
690
690
  soundMapping: { completed: 'snd-1' },
691
691
  imTargets: [],
692
692
  kindRoutes: { ask: ['im'] },
693
- webhookConfigured: true,
693
+ webhookUrl: 'https://hook.example/service/xxx',
694
694
  },
695
695
  )
696
- assert.equal(publicConfig({}).webhookConfigured, false)
697
- assert.equal(publicConfig({ webhookUrl: ' ' }).webhookConfigured, false)
698
- assert.equal('webhookUrl' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
696
+ assert.equal(publicConfig({}).webhookUrl, '')
697
+ // 空白串原样回显,不静默归一
698
+ assert.equal(publicConfig({ webhookUrl: ' ' }).webhookUrl, ' ')
699
+ assert.equal('webhookConfigured' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
699
700
  })
700
701
 
701
702
  test('会话事件有界累积:标题提取后封账为字符串,内存不再增长', () => {
@@ -0,0 +1,28 @@
1
+ // client 半区 React hooks 解构静态断言:client.js 为经典 bundle,组件渲染路径
2
+ // 无运行时测试,hooks 未从 React 解构的缺陷(useRef is not a function)只能靠
3
+ // 源码静态比对拦截——组件体内使用的全部 hooks 必须出现在解构清单。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { join, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
11
+ const source = readFileSync(join(root, 'src', 'client.js'), 'utf8')
12
+
13
+ test('client.js 组件使用的全部 React hooks 均在解构清单中', () => {
14
+ // Given 解构行(const { ... } = React)与全源码 hooks 调用
15
+ const destructure = /const\s*\{([^}]+)\}\s*=\s*React\b/.exec(source)
16
+ assert.notEqual(destructure, null, 'React 解构行必须存在')
17
+ const declared = new Set(destructure[1].split(',').map((name) => name.trim()).filter(Boolean))
18
+ const used = [...source.matchAll(/\b(use[A-Z]\w*)\s*\(/g)].map((match) => match[1])
19
+ assert.ok(used.length > 0, '至少存在一个 hooks 调用,否则断言失效')
20
+ // Then 每个使用的 hooks 都已声明
21
+ const missing = [...new Set(used)].filter((name) => !declared.has(name))
22
+ assert.deepEqual(missing, [], '未解构的 hooks(渲染即 ReferenceError)')
23
+ })
24
+
25
+ test('client.js 不存在 React.useXxx 直调形态(绕过解构清单)', () => {
26
+ const direct = [...source.matchAll(/\bReact\.(use[A-Z]\w*)\s*\(/g)].map((match) => match[1])
27
+ assert.deepEqual(direct, [], 'hooks 一律经解构清单使用,便于静态断言')
28
+ })
@@ -79,7 +79,7 @@ function makeCtx(extraServices) {
79
79
  return { ctx, routes }
80
80
  }
81
81
 
82
- test('config GET 返回解析后的默认配置,webhookUrl 凭据不出主机', async () => {
82
+ test('config GET 返回解析后的默认配置,webhookUrl 原文回显', async () => {
83
83
  const { ctx, routes } = makeCtx()
84
84
  apply(ctx)
85
85
  const handler = routes.get('/api/turn-notify/config')
@@ -87,8 +87,8 @@ test('config GET 返回解析后的默认配置,webhookUrl 凭据不出主机',
87
87
  const res = makeRes()
88
88
  await handler(makeReq('GET'), res)
89
89
  assert.equal(res.status, 200)
90
- assert.equal(res.body.webhookConfigured, false)
91
- assert.equal('webhookUrl' in res.body, false)
90
+ assert.equal(res.body.webhookUrl, '')
91
+ assert.equal('webhookConfigured' in res.body, false)
92
92
  assert.equal(res.body.minTurnDurationMs, MIN_TURN_MS)
93
93
  assert.equal(res.body.rootsOnly, true)
94
94
  assert.equal(res.body.enabled.completed, true)
@@ -105,12 +105,30 @@ test('config POST 非法补丁 400,合法补丁持久化且部分开关不覆盖
105
105
  const good = makeRes()
106
106
  await handler(makeReq('POST', { webhookUrl: 'https://hook.example', enabled: { completed: false } }, JSON_HEADERS), good)
107
107
  assert.equal(good.status, 200)
108
- assert.equal(good.body.webhookConfigured, true)
108
+ assert.equal(good.body.webhookUrl, 'https://hook.example')
109
109
  assert.equal(good.body.enabled.completed, false)
110
110
  assert.equal(good.body.enabled.error, true)
111
111
  const readback = makeRes()
112
112
  await handler(makeReq('GET'), readback)
113
- assert.equal(readback.body.webhookConfigured, true)
113
+ assert.equal(readback.body.webhookUrl, 'https://hook.example')
114
+ })
115
+
116
+ test('config POST 空串清空 webhookUrl:先存后清双向回环,清后通道禁用', async () => {
117
+ const { ctx, routes } = makeCtx()
118
+ apply(ctx)
119
+ const handler = routes.get('/api/turn-notify/config')
120
+ const save = makeRes()
121
+ await handler(makeReq('POST', { webhookUrl: 'https://hook.example/x' }, JSON_HEADERS), save)
122
+ assert.equal(save.status, 200)
123
+ assert.equal(save.body.webhookUrl, 'https://hook.example/x')
124
+ // 清空并保存=禁用:空串经校验落库,回读为空串而非回退旧值
125
+ const clear = makeRes()
126
+ await handler(makeReq('POST', { webhookUrl: '' }, JSON_HEADERS), clear)
127
+ assert.equal(clear.status, 200)
128
+ assert.equal(clear.body.webhookUrl, '')
129
+ const readback = makeRes()
130
+ await handler(makeReq('GET'), readback)
131
+ assert.equal(readback.body.webhookUrl, '')
114
132
  })
115
133
 
116
134
  test('config POST 负路径:跨源 403,非 JSON 400,畸形体 400,空补丁 200', async () => {
@@ -228,7 +246,7 @@ test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应
228
246
  const res = makeRes()
229
247
  await handler(makeReq('POST', { imTargets: [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }] }, JSON_HEADERS), res)
230
248
  assert.equal(res.status, 200)
231
- assert.equal(res.body.webhookConfigured, true)
249
+ assert.equal(res.body.webhookUrl, 'https://hooks.example.com/x')
232
250
  assert.equal(res.body.enabled.completed, false)
233
251
  assert.deepEqual(res.body.imTargets, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }])
234
252
  // 取消注册:整列表替换为空,其余配置仍原样
@@ -236,7 +254,7 @@ test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应
236
254
  await handler(makeReq('POST', { imTargets: [] }, JSON_HEADERS), clear)
237
255
  assert.equal(clear.status, 200)
238
256
  assert.deepEqual(clear.body.imTargets, [])
239
- assert.equal(clear.body.webhookConfigured, true)
257
+ assert.equal(clear.body.webhookUrl, 'https://hooks.example.com/x')
240
258
  assert.equal(clear.body.enabled.completed, false)
241
259
  })
242
260