@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 +4 -3
- package/package.json +1 -1
- package/src/client.js +21 -0
- package/src/core.mjs +8 -1
- package/src/index.js +70 -22
- package/test/core.test.mjs +10 -0
- package/test/event-wiring.test.mjs +21 -0
- package/test/route.test.mjs +138 -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。
|
|
@@ -191,4 +192,4 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
|
|
|
191
192
|
|
|
192
193
|
## dsh 版本兼容
|
|
193
194
|
|
|
194
|
-
|
|
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/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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 {
|
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)
|
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
|
+
})
|