@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 +3 -2
- package/package.json +1 -1
- package/src/client.js +21 -0
- package/src/core.mjs +8 -1
- package/src/index.js +4 -0
- package/test/core.test.mjs +10 -0
- package/test/event-wiring.test.mjs +21 -0
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
|
|
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 |
|
|
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
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 {
|
package/test/core.test.mjs
CHANGED
|
@@ -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)
|