dsh-ntfy-notifier 1.2.0 → 1.3.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 +30 -7
- package/client.js +109 -3
- package/index.js +129 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
- 支持 `title`、`message`、`priority`
|
|
18
18
|
- 优先级 4/5 可选用系统闹钟铃声,其他优先级使用普通提示音
|
|
19
19
|
- 消息通过 base64 JSON 传给 PowerShell,避免命令行和 XML 注入
|
|
20
|
-
-
|
|
20
|
+
- 支持全局 Bearer Token,也支持每个服务器节点单独配置并覆盖全局 Token
|
|
21
|
+
- 配置页按频道显示运行状态:等待、订阅完成、心跳正常、连接失败
|
|
21
22
|
- 插件停用或 DSH 退出时关闭连接和通知进程
|
|
22
23
|
|
|
23
24
|
## 环境要求
|
|
@@ -54,16 +55,19 @@
|
|
|
54
55
|
```text
|
|
55
56
|
服务器 1
|
|
56
57
|
服务器地址: [http://127.0.0.1:2586]
|
|
58
|
+
访问令牌: [留空则使用全局令牌]
|
|
57
59
|
频道: [message] [alerts] [+ 添加频道]
|
|
58
60
|
|
|
59
61
|
服务器 2
|
|
60
62
|
服务器地址: [https://ntfy.example.com]
|
|
63
|
+
访问令牌: [可填写该服务器自己的令牌]
|
|
61
64
|
频道: [system] [+ 添加频道]
|
|
62
65
|
|
|
63
66
|
[+ 添加服务器] [保存]
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
- 每个服务器地址是一个输入框
|
|
70
|
+
- 每个服务器可以单独填写访问令牌;留空时使用全局 `accessToken`
|
|
67
71
|
- 每个频道也是一个独立输入框
|
|
68
72
|
- 点 **+ 添加频道** 给当前服务器增加频道输入框
|
|
69
73
|
- 点 **+ 添加服务器** 增加一组服务器和频道输入框
|
|
@@ -83,10 +87,12 @@
|
|
|
83
87
|
topics:
|
|
84
88
|
- message
|
|
85
89
|
- alerts
|
|
90
|
+
accessToken: ''
|
|
86
91
|
- url: 'https://ntfy.example.com'
|
|
87
92
|
topics:
|
|
88
93
|
- system
|
|
89
|
-
|
|
94
|
+
accessToken: 'server-token'
|
|
95
|
+
accessToken: 'global-token'
|
|
90
96
|
appId: 'ntfy'
|
|
91
97
|
sound: true
|
|
92
98
|
highPriorityAlarm: true
|
|
@@ -96,8 +102,9 @@
|
|
|
96
102
|
|
|
97
103
|
| 字段 | 默认值 | 说明 |
|
|
98
104
|
|---|---|---|
|
|
99
|
-
| `servers` | 一个本地服务器和一个 `message` 频道 | 服务器列表,每项包含 `url`
|
|
100
|
-
| `accessToken` | 空 |
|
|
105
|
+
| `servers` | 一个本地服务器和一个 `message` 频道 | 服务器列表,每项包含 `url`、该服务器的 `topics` 和可选的 `accessToken` |
|
|
106
|
+
| `servers[].accessToken` | 空 | 可选,该服务器自己的 Bearer Token;非空时覆盖全局 `accessToken` |
|
|
107
|
+
| `accessToken` | 空 | 可选,全局 Bearer Token;服务器节点没有填写 `accessToken` 时使用 |
|
|
101
108
|
| `appId` | `ntfy` | Windows Toast 的 AUMID;未注册时自动回退到 `Microsoft.Windows.PowerShell` |
|
|
102
109
|
| `sound` | `true` | 是否播放通知声音 |
|
|
103
110
|
| `highPriorityAlarm` | `true` | 优先级 4/5 是否使用系统闹钟铃声 |
|
|
@@ -106,6 +113,21 @@
|
|
|
106
113
|
|
|
107
114
|
频道名不要包含空格或斜杠。如果使用反向代理子路径,`url` 填到子路径为止,例如 `https://ntfy.example.com/ntfy`,插件会请求 `https://ntfy.example.com/ntfy/<channel>/json`。
|
|
108
115
|
|
|
116
|
+
### 运行状态
|
|
117
|
+
|
|
118
|
+
配置页的每个频道旁会显示当前运行状态:
|
|
119
|
+
|
|
120
|
+
| 状态 | 颜色 | 含义 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| 等待 | 黄色 | 正在连接,或等待重连 |
|
|
123
|
+
| 订阅完成 | 绿色 | 已收到 ntfy 的 `open` 事件 |
|
|
124
|
+
| 心跳正常 | 绿色 | 已收到 keepalive 或消息 |
|
|
125
|
+
| 连接失败 | 红色 | 连续 3 次心跳检查没有收到任何数据 |
|
|
126
|
+
|
|
127
|
+
心跳检查每 45 秒执行一次;连续 3 次没有活动(约 135 秒)会标记为连接失败并触发重连。任何 keepalive 或消息都会重置心跳计数。
|
|
128
|
+
|
|
129
|
+
状态由 Host 提供给配置页,不包含 Token、Authorization 头或完整服务器地址。
|
|
130
|
+
|
|
109
131
|
## 工作原理
|
|
110
132
|
|
|
111
133
|
1. 对每个「服务器 + 频道」组合请求 `<server>/<channel>/json`,读取换行分隔的 JSON 事件。
|
|
@@ -118,7 +140,8 @@
|
|
|
118
140
|
|
|
119
141
|
- 不使用外部 `ntfy.exe`,减少一个可执行文件依赖。
|
|
120
142
|
- 消息内容不拼接进 PowerShell 命令行,也不直接拼进 XML,避免命令和 XML 注入。
|
|
121
|
-
- `accessToken` 会以明文保存在 DSH 的 profile 配置中;请确保本机账号和 profile 文件权限可信。
|
|
143
|
+
- `accessToken` 和 `servers[].accessToken` 会以明文保存在 DSH 的 profile 配置中;请确保本机账号和 profile 文件权限可信。
|
|
144
|
+
- 未配置服务器级 `accessToken` 的服务器会使用全局 `accessToken`;如果某台服务器不受信,建议不要设置全局令牌,或为每个服务器配置独立令牌。
|
|
122
145
|
- 局域网 HTTP 不加密。跨网络部署建议使用 HTTPS,并配置访问令牌。
|
|
123
146
|
- 插件只读取 ntfy 消息并将其显示为本机通知,不执行消息内容。
|
|
124
147
|
|
|
@@ -155,7 +178,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\notify.ps1 -PayloadBas
|
|
|
155
178
|
|
|
156
179
|
- **配置页看不到**:刷新 Web GUI;确认插件行是 `ntfy-notifier` 且处于启用状态;确认配置页的客户端模块已经加载。
|
|
157
180
|
- **没有通知**:检查 Windows 通知权限、专注助手/勿扰模式,以及 `appId` 是否被系统接受;可先用上面的助手命令测试。
|
|
158
|
-
-
|
|
181
|
+
- **连接失败**:确认服务器地址、频道名、反向代理路径,以及该服务器的 `servers[].accessToken` 或全局 `accessToken`;查看 DSH Host 日志中的 `ntfy:` 前缀。
|
|
159
182
|
- **频道重复**:同一个 URL 只会建立一条连接,重复频道会被去重。
|
|
160
183
|
|
|
161
184
|
## 限制
|
|
@@ -163,7 +186,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\notify.ps1 -PayloadBas
|
|
|
163
186
|
- 插件仅在 DSH 运行期间监听,不是常驻 Windows 服务。
|
|
164
187
|
- 当前只实现 Windows Toast;其他平台会记录警告但不会弹窗。
|
|
165
188
|
- 首次连接只接收新消息,不补发历史消息。
|
|
166
|
-
-
|
|
189
|
+
- 服务器节点没有填写 `accessToken` 时,会回退到全局 `accessToken`。
|
|
167
190
|
- 配置页依赖 DSH 的 `plugins.row.config` 和 `settings` 服务;也可以通过 YAML 配置。
|
|
168
191
|
|
|
169
192
|
## 许可证
|
package/client.js
CHANGED
|
@@ -11,6 +11,9 @@ window.__ModuleLoader__.load({
|
|
|
11
11
|
server: 'Server',
|
|
12
12
|
serverLabel: 'Server address',
|
|
13
13
|
serverHint: 'Base address, for example http://127.0.0.1:2586. A reverse-proxy path such as https://ntfy.example.com/ntfy is allowed.',
|
|
14
|
+
accessTokenLabel: 'Bearer Token',
|
|
15
|
+
accessTokenPlaceholder: 'Example: tk_**** (example only; enter your own)',
|
|
16
|
+
accessTokenHint: 'Optional. Leave empty to use the global Bearer Token (empty by default); a value here overrides it for this server.',
|
|
14
17
|
channelsLabel: 'Channels',
|
|
15
18
|
channelsHint: 'Each input is one channel. Use + to add another channel to this server.',
|
|
16
19
|
addServer: 'Add server',
|
|
@@ -27,7 +30,12 @@ window.__ModuleLoader__.load({
|
|
|
27
30
|
errorChannels: 'Enter at least one channel for this server.',
|
|
28
31
|
errorChannel: 'Channel names cannot contain spaces or slashes.',
|
|
29
32
|
saveFailed: 'The Host did not accept the configuration. The previous values are still active.',
|
|
30
|
-
noChannels: 'No channels'
|
|
33
|
+
noChannels: 'No channels',
|
|
34
|
+
statusWaiting: 'Waiting',
|
|
35
|
+
statusSubscribed: 'Subscribed',
|
|
36
|
+
statusHealthy: 'Heartbeat OK',
|
|
37
|
+
statusFailed: 'Connection failed',
|
|
38
|
+
statusUnavailable: 'Runtime status is unavailable.'
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
const zh = {
|
|
@@ -35,6 +43,9 @@ window.__ModuleLoader__.load({
|
|
|
35
43
|
server: '服务器',
|
|
36
44
|
serverLabel: '服务器地址',
|
|
37
45
|
serverHint: '填写基础地址,例如 http://127.0.0.1:2586;反向代理子路径也可以,例如 https://ntfy.example.com/ntfy。',
|
|
46
|
+
accessTokenLabel: 'Bearer Token',
|
|
47
|
+
accessTokenPlaceholder: '示例:tk_****(仅示例,需手动填写)',
|
|
48
|
+
accessTokenHint: '可选。留空时使用全局 Bearer Token(全局默认为空);填写后将覆盖该服务器的全局令牌。',
|
|
38
49
|
channelsLabel: '频道',
|
|
39
50
|
channelsHint: '每个输入框填一个频道;点 + 可以给当前服务器继续添加频道。',
|
|
40
51
|
addServer: '添加服务器',
|
|
@@ -51,7 +62,12 @@ window.__ModuleLoader__.load({
|
|
|
51
62
|
errorChannels: '请给这个服务器至少填写一个频道。',
|
|
52
63
|
errorChannel: '频道名不能包含空格或斜杠。',
|
|
53
64
|
saveFailed: 'Host 没有接受这次配置,当前仍使用原来的值。',
|
|
54
|
-
noChannels: '未配置频道'
|
|
65
|
+
noChannels: '未配置频道',
|
|
66
|
+
statusWaiting: '等待',
|
|
67
|
+
statusSubscribed: '订阅完成',
|
|
68
|
+
statusHealthy: '心跳正常',
|
|
69
|
+
statusFailed: '连接失败',
|
|
70
|
+
statusUnavailable: '运行状态暂不可用。'
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
const styles = {
|
|
@@ -186,6 +202,17 @@ window.__ModuleLoader__.load({
|
|
|
186
202
|
color: 'var(--dsw-alias-label-secondary)',
|
|
187
203
|
fontSize: '12px',
|
|
188
204
|
lineHeight: 1.5
|
|
205
|
+
},
|
|
206
|
+
statusBadge: {
|
|
207
|
+
display: 'inline-flex',
|
|
208
|
+
alignItems: 'center',
|
|
209
|
+
padding: '2px 7px',
|
|
210
|
+
border: '1px solid',
|
|
211
|
+
borderRadius: '999px',
|
|
212
|
+
background: 'transparent',
|
|
213
|
+
fontSize: '11px',
|
|
214
|
+
lineHeight: 1.4,
|
|
215
|
+
whiteSpace: 'nowrap'
|
|
189
216
|
}
|
|
190
217
|
}
|
|
191
218
|
|
|
@@ -240,6 +267,7 @@ window.__ModuleLoader__.load({
|
|
|
240
267
|
const item = plain(entry) || {}
|
|
241
268
|
return {
|
|
242
269
|
url: typeof item.url === 'string' ? item.url : '',
|
|
270
|
+
accessToken: typeof item.accessToken === 'string' ? item.accessToken : '',
|
|
243
271
|
topics: Array.isArray(item.topics)
|
|
244
272
|
? item.topics.filter((topic) => typeof topic === 'string' && topic.trim()).map((topic) => topic.trim())
|
|
245
273
|
: []
|
|
@@ -247,7 +275,7 @@ window.__ModuleLoader__.load({
|
|
|
247
275
|
})
|
|
248
276
|
.filter((entry) => entry.url || entry.topics.length > 0)
|
|
249
277
|
: []
|
|
250
|
-
return { servers: servers.length > 0 ? servers : [{ url: '', topics: [] }] }
|
|
278
|
+
return { servers: servers.length > 0 ? servers : [{ url: '', accessToken: '', topics: [] }] }
|
|
251
279
|
}
|
|
252
280
|
|
|
253
281
|
function nextId() {
|
|
@@ -263,6 +291,7 @@ window.__ModuleLoader__.load({
|
|
|
263
291
|
return {
|
|
264
292
|
id: nextId(),
|
|
265
293
|
url: typeof server?.url === 'string' ? server.url : '',
|
|
294
|
+
accessToken: typeof server?.accessToken === 'string' ? server.accessToken : '',
|
|
266
295
|
channels: topics.length > 0 ? topics.map(channelDraft) : [channelDraft('')]
|
|
267
296
|
}
|
|
268
297
|
}
|
|
@@ -278,12 +307,45 @@ window.__ModuleLoader__.load({
|
|
|
278
307
|
const [error, setError] = React.useState('')
|
|
279
308
|
const [saved, setSaved] = React.useState(false)
|
|
280
309
|
const [saving, setSaving] = React.useState(false)
|
|
310
|
+
const [runtimeStatus, setRuntimeStatus] = React.useState([])
|
|
311
|
+
const [statusError, setStatusError] = React.useState(false)
|
|
281
312
|
|
|
282
313
|
React.useEffect(() => {
|
|
283
314
|
setServers(initial.servers.map(serverDraft))
|
|
284
315
|
setError('')
|
|
285
316
|
}, [initial])
|
|
286
317
|
|
|
318
|
+
React.useEffect(() => {
|
|
319
|
+
if (view !== 'page') return
|
|
320
|
+
let cancelled = false
|
|
321
|
+
let timer
|
|
322
|
+
|
|
323
|
+
const poll = async () => {
|
|
324
|
+
try {
|
|
325
|
+
const response = await fetch('/ntfy-notifier/status', { cache: 'no-store' })
|
|
326
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
327
|
+
const data = await response.json()
|
|
328
|
+
if (!cancelled) {
|
|
329
|
+
setRuntimeStatus(Array.isArray(data?.subscriptions) ? data.subscriptions : [])
|
|
330
|
+
setStatusError(false)
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
if (!cancelled) {
|
|
334
|
+
setRuntimeStatus([])
|
|
335
|
+
setStatusError(true)
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
if (!cancelled) timer = setTimeout(poll, 2_000)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
poll()
|
|
343
|
+
return () => {
|
|
344
|
+
cancelled = true
|
|
345
|
+
clearTimeout(timer)
|
|
346
|
+
}
|
|
347
|
+
}, [view])
|
|
348
|
+
|
|
287
349
|
if (view === 'summary') {
|
|
288
350
|
const description = initial.servers
|
|
289
351
|
.map((server) => {
|
|
@@ -300,6 +362,29 @@ window.__ModuleLoader__.load({
|
|
|
300
362
|
|
|
301
363
|
const disabled = !state.writable || saving
|
|
302
364
|
|
|
365
|
+
function statusFor(serverIndex, topic) {
|
|
366
|
+
if (!topic) return undefined
|
|
367
|
+
return runtimeStatus.find((item) => item.serverIndex === serverIndex && item.topic === topic)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function statusLabel(phase) {
|
|
371
|
+
switch (phase) {
|
|
372
|
+
case 'subscribed': return t('statusSubscribed')
|
|
373
|
+
case 'healthy': return t('statusHealthy')
|
|
374
|
+
case 'failed': return t('statusFailed')
|
|
375
|
+
default: return t('statusWaiting')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function statusStyle(phase) {
|
|
380
|
+
const color = phase === 'failed'
|
|
381
|
+
? 'var(--dsw-alias-state-error-primary)'
|
|
382
|
+
: phase === 'waiting'
|
|
383
|
+
? 'var(--dsw-alias-state-warn-primary)'
|
|
384
|
+
: 'var(--dsw-alias-state-success-primary)'
|
|
385
|
+
return { ...styles.statusBadge, color, borderColor: color }
|
|
386
|
+
}
|
|
387
|
+
|
|
303
388
|
function updateServer(id, update) {
|
|
304
389
|
setServers((current) => current.map((server) => (server.id === id ? { ...server, ...update } : server)))
|
|
305
390
|
setSaved(false)
|
|
@@ -356,6 +441,7 @@ window.__ModuleLoader__.load({
|
|
|
356
441
|
try {
|
|
357
442
|
return {
|
|
358
443
|
url: normalizeServer(server.url, t),
|
|
444
|
+
accessToken: String(server.accessToken || '').trim(),
|
|
359
445
|
topics: validateChannels(server.channels.map((channel) => channel.value), t)
|
|
360
446
|
}
|
|
361
447
|
} catch (cause) {
|
|
@@ -381,6 +467,7 @@ window.__ModuleLoader__.load({
|
|
|
381
467
|
|
|
382
468
|
return h('div', { style: styles.root },
|
|
383
469
|
h('p', { style: styles.intro }, t('intro')),
|
|
470
|
+
statusError ? h('p', { style: styles.hint }, t('statusUnavailable')) : null,
|
|
384
471
|
servers.map((server, index) => h('section', {
|
|
385
472
|
key: server.id,
|
|
386
473
|
style: styles.serverCard
|
|
@@ -410,6 +497,20 @@ window.__ModuleLoader__.load({
|
|
|
410
497
|
})
|
|
411
498
|
),
|
|
412
499
|
h('p', { style: styles.hint }, t('serverHint')),
|
|
500
|
+
h('label', { style: styles.label },
|
|
501
|
+
t('accessTokenLabel'),
|
|
502
|
+
h('input', {
|
|
503
|
+
type: 'password',
|
|
504
|
+
style: styles.input,
|
|
505
|
+
value: server.accessToken,
|
|
506
|
+
disabled,
|
|
507
|
+
spellCheck: false,
|
|
508
|
+
autoComplete: 'off',
|
|
509
|
+
placeholder: t('accessTokenPlaceholder'),
|
|
510
|
+
onChange: (event) => updateServer(server.id, { accessToken: event.target.value })
|
|
511
|
+
})
|
|
512
|
+
),
|
|
513
|
+
h('p', { style: styles.hint }, t('accessTokenHint')),
|
|
413
514
|
h('div', { style: styles.serverHeader },
|
|
414
515
|
h('span', { style: styles.channelLabel }, t('channelsLabel')),
|
|
415
516
|
h('button', {
|
|
@@ -434,6 +535,11 @@ window.__ModuleLoader__.load({
|
|
|
434
535
|
'aria-label': `${t('channelsLabel')} ${channelIndex + 1}`,
|
|
435
536
|
onChange: (event) => updateChannel(server.id, channel.id, event.target.value)
|
|
436
537
|
}),
|
|
538
|
+
channel.value.trim()
|
|
539
|
+
? h('span', {
|
|
540
|
+
style: statusStyle(statusFor(index, channel.value.trim())?.phase || 'waiting')
|
|
541
|
+
}, statusLabel(statusFor(index, channel.value.trim())?.phase || 'waiting'))
|
|
542
|
+
: null,
|
|
437
543
|
server.channels.length > 1
|
|
438
544
|
? h('button', {
|
|
439
545
|
type: 'button',
|
package/index.js
CHANGED
|
@@ -19,10 +19,13 @@ import { fileURLToPath } from 'node:url'
|
|
|
19
19
|
import z from '@deepseek-ai/schemastery'
|
|
20
20
|
|
|
21
21
|
const NOTIFY_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'notify.ps1')
|
|
22
|
-
const USER_AGENT = 'dsh-ntfy-notifier/1.
|
|
22
|
+
const USER_AGENT = 'dsh-ntfy-notifier/1.3.0'
|
|
23
23
|
const SEEN_LIMIT = 300
|
|
24
24
|
const QUEUE_LIMIT = 50
|
|
25
|
-
const
|
|
25
|
+
const HEARTBEAT_INTERVAL_MS = 45_000
|
|
26
|
+
const HEARTBEAT_MISS_LIMIT = 3
|
|
27
|
+
const HEARTBEAT_CHECK_MS = 45_000
|
|
28
|
+
const REQUEST_IDLE_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * HEARTBEAT_MISS_LIMIT + 30_000
|
|
26
29
|
const TOAST_TIMEOUT_MS = 20_000
|
|
27
30
|
const MAX_TITLE = 120
|
|
28
31
|
const MAX_BODY = 1_200
|
|
@@ -31,15 +34,16 @@ export const Config = z.object({
|
|
|
31
34
|
servers: z
|
|
32
35
|
.array(z.object({
|
|
33
36
|
url: z.string(),
|
|
34
|
-
topics: z.array(z.string()).default([])
|
|
37
|
+
topics: z.array(z.string()).default([]),
|
|
38
|
+
accessToken: z.string().default('')
|
|
35
39
|
}))
|
|
36
40
|
.default([])
|
|
37
41
|
.volatile()
|
|
38
|
-
.description('ntfy 服务器列表,每项包含服务器基础地址 url
|
|
42
|
+
.description('ntfy 服务器列表,每项包含服务器基础地址 url、频道名数组 topics 和可选的服务器级 accessToken。'),
|
|
39
43
|
accessToken: z
|
|
40
44
|
.string()
|
|
41
45
|
.default('')
|
|
42
|
-
.description('
|
|
46
|
+
.description('可选:所有服务器默认使用的 ntfy 访问令牌。服务器节点填写 accessToken 时优先使用节点值。'),
|
|
43
47
|
appId: z
|
|
44
48
|
.string()
|
|
45
49
|
.default('ntfy')
|
|
@@ -133,6 +137,7 @@ function resolveTargets(config, logger) {
|
|
|
133
137
|
|
|
134
138
|
const targets = []
|
|
135
139
|
const seen = new Set()
|
|
140
|
+
const globalToken = typeof config.accessToken === 'string' ? config.accessToken.trim() : ''
|
|
136
141
|
rawServers.forEach((entry, index) => {
|
|
137
142
|
const value = configValue(entry) || {}
|
|
138
143
|
let server
|
|
@@ -150,6 +155,8 @@ function resolveTargets(config, logger) {
|
|
|
150
155
|
logger.warn(`ntfy: 服务器 ${index + 1} 没有频道,已跳过`)
|
|
151
156
|
return
|
|
152
157
|
}
|
|
158
|
+
const serverToken = typeof value.accessToken === 'string' ? value.accessToken.trim() : ''
|
|
159
|
+
const token = serverToken || globalToken
|
|
153
160
|
for (const topic of topics) {
|
|
154
161
|
try {
|
|
155
162
|
const url = topicUrlFor(server, topic)
|
|
@@ -158,7 +165,13 @@ function resolveTargets(config, logger) {
|
|
|
158
165
|
continue
|
|
159
166
|
}
|
|
160
167
|
seen.add(url.href)
|
|
161
|
-
targets.push({
|
|
168
|
+
targets.push({
|
|
169
|
+
url,
|
|
170
|
+
label: `${server.host}/${topicLabel(url)}`,
|
|
171
|
+
accessToken: token,
|
|
172
|
+
serverIndex: index,
|
|
173
|
+
topic
|
|
174
|
+
})
|
|
162
175
|
} catch (error) {
|
|
163
176
|
logger.warn(`ntfy: 跳过无效频道「${topic}」:${error instanceof Error ? error.message : String(error)}`)
|
|
164
177
|
}
|
|
@@ -290,7 +303,7 @@ function createToastSender(logger) {
|
|
|
290
303
|
}
|
|
291
304
|
|
|
292
305
|
/** One independent ntfy subscription and its reconnect state. */
|
|
293
|
-
function createSubscription({ topicUrl, config, logger, toast }) {
|
|
306
|
+
function createSubscription({ topicUrl, accessToken, status, config, logger, toast }) {
|
|
294
307
|
const label = topicLabel(topicUrl)
|
|
295
308
|
const logPrefix = `ntfy[${label}]`
|
|
296
309
|
const seen = new Set()
|
|
@@ -300,11 +313,51 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
300
313
|
timer: undefined,
|
|
301
314
|
active: undefined,
|
|
302
315
|
lastId: '',
|
|
303
|
-
attempt: 0
|
|
316
|
+
attempt: 0,
|
|
317
|
+
heartbeatTimer: undefined
|
|
304
318
|
}
|
|
305
319
|
const baseDelay = config.reconnectDelaySeconds * 1_000
|
|
306
320
|
const maxDelay = Math.max(baseDelay, config.maxReconnectDelaySeconds * 1_000)
|
|
307
|
-
const token =
|
|
321
|
+
const token = typeof accessToken === 'string' ? accessToken.trim() : ''
|
|
322
|
+
|
|
323
|
+
function setPhase(phase) {
|
|
324
|
+
if (state.disposed) return
|
|
325
|
+
status.phase = phase
|
|
326
|
+
status.updatedAt = Date.now()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function markActivity() {
|
|
330
|
+
if (state.disposed) return
|
|
331
|
+
status.lastActivity = Date.now()
|
|
332
|
+
status.missed = 0
|
|
333
|
+
status.updatedAt = status.lastActivity
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function stopHeartbeat() {
|
|
337
|
+
if (state.heartbeatTimer) clearInterval(state.heartbeatTimer)
|
|
338
|
+
state.heartbeatTimer = undefined
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function startHeartbeat() {
|
|
342
|
+
stopHeartbeat()
|
|
343
|
+
markActivity()
|
|
344
|
+
state.heartbeatTimer = setInterval(() => {
|
|
345
|
+
if (state.disposed) return
|
|
346
|
+
const idle = Date.now() - status.lastActivity
|
|
347
|
+
if (idle < HEARTBEAT_INTERVAL_MS) {
|
|
348
|
+
status.missed = 0
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
status.missed += 1
|
|
352
|
+
status.updatedAt = Date.now()
|
|
353
|
+
if (status.missed < HEARTBEAT_MISS_LIMIT) return
|
|
354
|
+
setPhase('failed')
|
|
355
|
+
stopHeartbeat()
|
|
356
|
+
logger.warn(`${logPrefix}: 连续 ${HEARTBEAT_MISS_LIMIT} 次心跳失败,重连`)
|
|
357
|
+
state.active?.req?.destroy()
|
|
358
|
+
}, HEARTBEAT_CHECK_MS)
|
|
359
|
+
state.heartbeatTimer.unref?.()
|
|
360
|
+
}
|
|
308
361
|
|
|
309
362
|
function remember(id) {
|
|
310
363
|
if (!id) return true
|
|
@@ -330,6 +383,8 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
330
383
|
function finish(reason, req, res) {
|
|
331
384
|
if (state.active?.req !== req) return
|
|
332
385
|
state.active = undefined
|
|
386
|
+
stopHeartbeat()
|
|
387
|
+
setPhase('failed')
|
|
333
388
|
if (res && !res.destroyed) res.destroy()
|
|
334
389
|
if (req && !req.destroyed) req.destroy()
|
|
335
390
|
scheduleReconnect(reason)
|
|
@@ -374,16 +429,26 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
374
429
|
if (payload.event === 'open') {
|
|
375
430
|
logger.info(`${logPrefix}: 订阅已建立`)
|
|
376
431
|
state.attempt = 0
|
|
432
|
+
setPhase('subscribed')
|
|
433
|
+
startHeartbeat()
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (payload.event === 'keepalive') {
|
|
437
|
+
markActivity()
|
|
438
|
+
setPhase('healthy')
|
|
377
439
|
return
|
|
378
440
|
}
|
|
379
|
-
if (payload.event === 'keepalive') return
|
|
380
441
|
if (payload.event === 'message' || (payload.event === undefined && ('message' in payload || 'title' in payload))) {
|
|
442
|
+
markActivity()
|
|
443
|
+
setPhase('healthy')
|
|
381
444
|
notify(payload)
|
|
382
445
|
}
|
|
383
446
|
}
|
|
384
447
|
|
|
385
448
|
function connect() {
|
|
386
449
|
if (state.disposed) return
|
|
450
|
+
stopHeartbeat()
|
|
451
|
+
setPhase('waiting')
|
|
387
452
|
const url = streamUrlFrom(topicUrl, state.lastId)
|
|
388
453
|
const transport = url.protocol === 'https:' ? httpsRequest : httpRequest
|
|
389
454
|
let settled = false
|
|
@@ -393,6 +458,9 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
393
458
|
url,
|
|
394
459
|
{
|
|
395
460
|
method: 'GET',
|
|
461
|
+
// Node's global HTTP agent enables TCP keepalive at 1s intervals,
|
|
462
|
+
// which produces unnecessary per-second probe packets on this stream.
|
|
463
|
+
agent: false,
|
|
396
464
|
headers: {
|
|
397
465
|
accept: 'application/x-ntfy/stream, application/json, text/event-stream',
|
|
398
466
|
'user-agent': USER_AGENT,
|
|
@@ -441,6 +509,7 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
441
509
|
let buffer = ''
|
|
442
510
|
res.setEncoding('utf8')
|
|
443
511
|
res.on('data', (chunk) => {
|
|
512
|
+
markActivity()
|
|
444
513
|
buffer += chunk
|
|
445
514
|
if (buffer.length > 2_000_000) {
|
|
446
515
|
buffer = ''
|
|
@@ -491,6 +560,7 @@ function createSubscription({ topicUrl, config, logger, toast }) {
|
|
|
491
560
|
function dispose() {
|
|
492
561
|
if (state.disposed) return
|
|
493
562
|
state.disposed = true
|
|
563
|
+
stopHeartbeat()
|
|
494
564
|
if (state.timer) clearTimeout(state.timer)
|
|
495
565
|
state.timer = undefined
|
|
496
566
|
const active = state.active
|
|
@@ -527,11 +597,48 @@ export function apply(ctx, config) {
|
|
|
527
597
|
|
|
528
598
|
const toast = createToastSender(logger)
|
|
529
599
|
let subscriptions = []
|
|
600
|
+
let statuses = []
|
|
530
601
|
let stopped = false
|
|
531
602
|
|
|
603
|
+
function serveStatus(req, res) {
|
|
604
|
+
if (req.method !== 'GET') {
|
|
605
|
+
res.writeHead(405, { allow: 'GET' })
|
|
606
|
+
res.end()
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
res.writeHead(200, {
|
|
610
|
+
'content-type': 'application/json; charset=utf-8',
|
|
611
|
+
'cache-control': 'no-store'
|
|
612
|
+
})
|
|
613
|
+
res.end(JSON.stringify({
|
|
614
|
+
subscriptions: statuses.map((status) => ({
|
|
615
|
+
id: status.id,
|
|
616
|
+
serverIndex: status.serverIndex,
|
|
617
|
+
topic: status.topic,
|
|
618
|
+
phase: status.phase,
|
|
619
|
+
missed: status.missed,
|
|
620
|
+
lastActivity: status.lastActivity,
|
|
621
|
+
updatedAt: status.updatedAt
|
|
622
|
+
}))
|
|
623
|
+
}))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
ctx.inject(['webServer'], (scope) => {
|
|
628
|
+
scope.effect(() => scope.webServer.register({
|
|
629
|
+
kind: 'exact',
|
|
630
|
+
path: '/ntfy-notifier/status',
|
|
631
|
+
handler: serveStatus
|
|
632
|
+
}), 'ntfy-notifier status route')
|
|
633
|
+
})
|
|
634
|
+
} catch (error) {
|
|
635
|
+
logger.warn(`ntfy: status route registration failed: ${String(error)}`)
|
|
636
|
+
}
|
|
637
|
+
|
|
532
638
|
function stopSubscriptions() {
|
|
533
639
|
for (const subscription of subscriptions) subscription.dispose()
|
|
534
640
|
subscriptions = []
|
|
641
|
+
statuses = []
|
|
535
642
|
}
|
|
536
643
|
|
|
537
644
|
function startSubscriptions() {
|
|
@@ -550,8 +657,19 @@ export function apply(ctx, config) {
|
|
|
550
657
|
return
|
|
551
658
|
}
|
|
552
659
|
|
|
553
|
-
|
|
660
|
+
statuses = targets.map((target) => ({
|
|
661
|
+
id: `${target.serverIndex}:${target.topic}`,
|
|
662
|
+
serverIndex: target.serverIndex,
|
|
663
|
+
topic: target.topic,
|
|
664
|
+
phase: 'waiting',
|
|
665
|
+
missed: 0,
|
|
666
|
+
lastActivity: 0,
|
|
667
|
+
updatedAt: Date.now()
|
|
668
|
+
}))
|
|
669
|
+
subscriptions = targets.map((target, index) => createSubscription({
|
|
554
670
|
topicUrl: target.url,
|
|
671
|
+
accessToken: target.accessToken,
|
|
672
|
+
status: statuses[index],
|
|
555
673
|
config,
|
|
556
674
|
logger,
|
|
557
675
|
toast
|
package/package.json
CHANGED