dsh-notify-me 1.12.0 → 1.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.13.0] — 2026-10-23
6
+
7
+ ### 新增
8
+
9
+ - **外部推送新增三种国内常用目标**(来自 [#17](https://github.com/chromoany/dsh-notify-me/issues/17) 的安卓端诉求):`feishu`(飞书群机器人)、`serverchan`(Server酱 Turbo 与 Server酱³ 同一套 `…/send/<SendKey>.send` 地址 + `title`/`desp` 表单)、`wxpusher`(`appToken` + 接收者 UID,端点固定,没有可填错的 URL)。三种都走官方接口,浏览器页面能直连确认送达——飞书返回 `Access-Control-Allow-Origin: *`,Server酱与 WxPusher 回显页面 Origin。
10
+ - **失败自动重试与失败记录**:明显临时的失败(网络中断、HTTP 5xx、429)按 `externalRetryDelays`(默认 2 秒 / 6 秒,带 ±25% 抖动)退避重试;其它错误立即停。重试仍失败的投递写进「失败记录」(最多 10 条,只留标题与正文摘要、事件类型与主机名,不留完整端点),设置页可查看与清空。
11
+ - **每个目标可单独启停、并显示最近一次投递状态**:目标行右侧显示「上次成功 / 上次已送出(不可确认)/ 上次失败 + 原因 + 时间」,旁边一个独立开关(`enabled: false`)——某个端点临时不可用就单独关掉它,不必关掉整类事件。
12
+ - **出站签名**:自定义目标填了签名密钥时,请求带 `X-DSH-Notify-Signature`(对请求体做 HMAC-SHA256,`sha256=<hex>`)与 `X-DSH-Notify-Timestamp`;飞书目标填了密钥时按官方算法在请求体里带 `timestamp`(秒)/ `sign`(`base64(HMAC(key = timestamp + "\n" + secret, data = ""))`)。
13
+ - **消息模板**:飞书 / Server酱 / WxPusher / 自定义目标可填模板,占位符 `{{title}}` `{{body}}` `{{event}}` `{{time}}` `{{session}}`;留空是「标题 + 换行 + 正文」。
14
+ - 服务端返回的失败原因如实写进状态:飞书/Server酱/WxPusher 的 `code` 判成功(WxPusher 为 `1000`),失败带上 `error` / `message` / `info` / `msg`。
15
+
16
+ ### 说明
17
+
18
+ - **钉钉群机器人没有做成目标类型**:实测其接口不返回任何 CORS 头,且只接受 `application/json`——浏览器页面既读不到响应,也无法在 no-cors 下发出合规请求。文档与设置页提示:自建一个中转,用「自定义 Webhook」指向它。
19
+ - **PushDeer / ntfy 同样没有做**:PushDeer 官方安卓客户端已停止维护、接口无 CORS 头;ntfy 自建在安卓上依赖常连保活、默认 `priority` 低于 4 不弹横幅。需要时仍可用自定义目标接自建 ntfy。
20
+
21
+ ### 测试
22
+
23
+ - 冒烟新增:4 种目标各发一次并逐个校验报文形状(飞书 text + 签名、Server酱 form 编码、WxPusher token/uids、自定义模板与 HMAC 头);网络错误/5xx 重试到耗尽进失败记录、4xx 不重试、第二次成功只记 `sent`;目标级 `enabled:false` 被裁剪;设置页的类型专属字段、目标启停开关、状态文案、失败记录展示与清空。
24
+
25
+ ### 文档
26
+
27
+ - README 中英:外部推送整节重写(目标类型、可靠性、状态、签名、模板、钉钉为何不支持),控制台示例补 `externalRetryDelays` 与目标字段形状,已知限制口径同步;致谢加上 [@yuexian7](https://github.com/yuexian7) 与 #17。
28
+
5
29
  ## [1.12.0] — 2026-10-21
6
30
 
7
31
  ### 新增
package/README.en.md CHANGED
@@ -26,11 +26,11 @@ A notification plugin for DSH Web / Desktop, so you can keep full access off wit
26
26
  - **When input is needed:** approval, plan review, and questions trigger notifications, sound, and a title marker.
27
27
  - **When a background reply finishes:** return to the result; click the notification to open its conversation. Pick "background only", "quiet for the open one", or "always".
28
28
  - **When you walk off but the window stays in front:** over 3 minutes without input counts as away, and the alert still fires; a toast you have not seen is not closed on a timer before you return.
29
- - **When you leave your computer:** optionally forward alerts to Discord, Bark, Gotify, or your own webhook.
29
+ - **When you leave your computer:** optionally forward alerts to Discord, Bark, Gotify, Feishu group bots, ServerChan, WxPusher, or your own webhook.
30
30
 
31
31
  > Keep the page or desktop app running; backgrounding / minimizing is fine. System notifications require permission. Approval buttons need a Service Worker: Web (`http://127.0.0.1` or `https`) has one, the Desktop `dsh-app://` pages cannot register one, so their notifications carry no buttons — click the notification to decide in the conversation.
32
32
 
33
- > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert**, **external notifications**, **Discord / Bark / Gotify push** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
33
+ > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert**, **external notifications**, **Android push**, **Discord / Bark / Gotify / Feishu / ServerChan / WxPusher push** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
34
34
 
35
35
  ## UI Preview
36
36
 
@@ -118,7 +118,7 @@ Open **Settings → Notify me**. The page runs as how you get told, when to aler
118
118
 
119
119
  - **Enable reminders**: master switch — off means no toasts, no sounds, and the tab title is left alone;
120
120
  - **Sound / Volume**: the switch is direct; the slider folds into that row — click the title (▸ turns into ▾) to reveal it. What it plays comes from Advanced → Alert sound;
121
- - **Decide approvals from the toast** (on by default): approvals carry Approve / Reject that settle the request in place. On Web that is a system notification with buttons; on Desktop `dsh-app://` cannot register a Service Worker, so the notification carries no buttons — click it to decide in the conversation. The line underneath states whether buttons are possible here.
121
+ - **Decide approvals from the toast** (on by default): approvals carry Approve / Reject that settle the request in place. On Web that is a system notification with buttons; on Desktop `dsh-app://` cannot register a Service Worker, so the notification carries no buttons — click it to decide in the conversation. The line under the Try-it block states whether buttons are possible here.
122
122
 
123
123
  ### When to alert
124
124
 
@@ -152,12 +152,17 @@ Choose a built-in cue, custom audio file, or system notification sound independe
152
152
 
153
153
  ### External push
154
154
 
155
- Off by default. Supports Discord Webhook / Bark / Gotify / custom webhooks. Alert text is sent to the addresses you enter; use trusted endpoints.
155
+ Off by default. Supports Discord Webhook / Bark / Gotify / Feishu group bots / ServerChan (Turbo and ServerChan³) / WxPusher / custom webhooks. Alert text is sent to the addresses you enter; use trusted endpoints.
156
156
 
157
157
  <details>
158
- <summary>Target URLs, forwarding switches, and test messages</summary>
158
+ <summary>Target URLs, forwarding switches, test messages, and the failure log</summary>
159
159
 
160
- - **External notifications** (default off) — forward each alert to your own push endpoints as well, so it reaches you away from the machine: **Discord Webhook** / **Bark** / **Gotify** / **custom webhook**, any number of targets, each with a type and URL (Discord takes the webhook URL; Bark `https://api.day.app/<device key>`; Gotify `https://<server>/message?token=<app token>`; a custom endpoint receives one POST of JSON: `{source, event, title, body, sessionId, time}`). "Forward 'needs you'" and "Forward 'reply finished'" switch independently; **Send test notification** sends one test message to every target (not limited by those two switches, and never pretending to be a real alert). Forwarding follows exactly the same trigger rules as local alerts ("stay quiet for the conversation on screen" and friends apply to it too); the alert text is sent as-is to the addresses you fill in, so **only use endpoints you trust**;
160
+ - **External notifications** (default off) — forward each alert to your own push endpoints as well, so it reaches you away from the machine: **Discord Webhook** / **Bark** / **Gotify** / **Feishu group bot** / **ServerChan** / **WxPusher** / **custom webhook**, any number of targets, each of which can be enabled or disabled on its own (Discord takes the webhook URL; Bark `https://api.day.app/<device key>`; Gotify `https://<server>/message?token=<app token>`; Feishu the group-bot webhook plus an optional verification secret; ServerChan the full `…/send/<SendKey>.send` URL; WxPusher the appToken and the receiver UID; a custom endpoint receives one POST of JSON: `{source, event, title, body, text, sessionId, time}`). "Forward 'needs you'" and "Forward 'reply finished'" switch independently; **Send test notification** sends one test message to every target (not limited by those two switches, and never pretending to be a real alert). Forwarding follows exactly the same trigger rules as local alerts ("stay quiet for the conversation on screen" and friends apply to it too); the alert text is sent as-is to the addresses you fill in, so **only use endpoints you trust**;
161
+ - **Reliable delivery** — failures that look transient (network error, 5xx, 429) are retried after 2 s and 6 s; anything else stops at once. A delivery that still fails is recorded in the failure log (10 entries at most — title and body snippet plus host only, never the full endpoint), which the settings page shows and can clear;
162
+ - **Per-target state** — the right end of each target row shows how its last delivery went (last delivery ok / sent-unverified / failed with the reason) next to a switch of its own, so an endpoint that is temporarily down can be turned off alone instead of muting a whole alert kind;
163
+ - **Signing** — a custom target with a secret sends `X-DSH-Notify-Signature` (HMAC-SHA256 over the body, `sha256=<hex>`); a Feishu target with a secret carries `timestamp` / `sign` in the body per Feishu's own algorithm;
164
+ - **Message template** (optional) — Feishu / ServerChan / WxPusher / custom targets accept a template with `{{title}}` `{{body}}` `{{event}}` `{{time}}` `{{session}}`; empty means "title, newline, body";
165
+ - **DingTalk is not supported** — its robot endpoint answers no CORS header at all and only accepts `application/json`, so a page can neither read the reply nor send a conforming request (under no-cors the content type is restricted to the safelisted ones). To reach DingTalk, relay it through a machine you control and point a custom webhook at that.
161
166
 
162
167
  </details>
163
168
 
@@ -183,7 +188,7 @@ The last block inside Advanced. The page protocol is detected automatically to t
183
188
  - **System notification** — a toast through the browser into the Windows action center / macOS Notification Center (clicking it opens the conversation it came from; supported Web environments offer approval buttons, Desktop does not)
184
189
  - **Sound** — WebAudio beeps by default (distinct patterns for "needs you" vs "done"); each kind can instead play a picked audio file, or hand the sound over to the operating system's own notification sound
185
190
  - **Tab title marker** — while something is waiting on you, the tab title is prefixed with `🔔 Action needed · …` / `🔔 需要你 · …`; while a background session is **finished but unread**, with `✅ Reply finished · …` / `✅ 回复完成 · …`, gone once you open that session (or it starts running again). Both kinds can be up together, "needs you" first; a host title change is rebased under the markers
186
- - **External notifications** (optional, default off) — the same alert is also forwarded to the push targets you configure: Discord Webhook / Bark / Gotify / custom webhook — so the push channel on your phone picks it up while you step away from the machine
191
+ - **External notifications** (optional, default off) — the same alert is also forwarded to the push targets you configure: Discord Webhook / Bark / Gotify / Feishu group bot / ServerChan / WxPusher / custom webhook — so the push channel on your phone picks it up while you step away from the machine
187
192
 
188
193
  This is a **browser-layer** plugin: the DSH page must stay open (minimized or backgrounded is fine — that's exactly the "away" state it watches for).
189
194
 
@@ -241,8 +246,14 @@ window.__dshNotifyMe.setConfig({
241
246
  externalEnabled: false, // external-notification forwarding master switch (default off: no endpoint, nothing is sent)
242
247
  externalAttention: true, // forward "needs you" (exactly the same trigger rules as local alerts)
243
248
  externalDone: true, // forward "reply finished"
244
- externalTargets: [] // push targets: [{id, type:'discord'|'bark'|'gotify'|'custom', url}]
249
+ externalRetryDelays: [2000, 6000], // backoff for retryable failures (ms, with jitter; [] = send once)
250
+ externalTargets: [] // push targets, see the shape below
245
251
  })
252
+ // Target shape: {id, type, url, secret?, token?, uid?, template?, enabled?}
253
+ // type: 'discord' | 'bark' | 'gotify' | 'feishu' | 'serverchan' | 'wxpusher' | 'custom'
254
+ // token/uid only for wxpusher (its URL is fixed to the official endpoint); secret signs Feishu bodies
255
+ // and custom HMAC headers; template is optional ({{title}} {{body}} {{event}} {{time}} {{session}});
256
+ // enabled:false disables that one target
246
257
  window.__dshNotifyMe.resetConfig() // restore defaults (also drops any picked audio files)
247
258
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // programmatic decision: "allowed-once" | "rejected"
248
259
  window.__dshNotifyMe.testExternal() // explicit outbound test to valid targets; resolves {sent, unverified, failed, statuses}
@@ -300,13 +311,13 @@ There is no page-side fallback either: the shell preload only exposes `dshDeskto
300
311
  - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
301
312
  - On the Desktop shell (DSH Desktop 0.2.1-rc.2 / Windows) a toast click does not reliably bring the window forward: sometimes it does, sometimes only the conversation switches. The plugin's `dsh://open` deep link on its own failed 3/3 in isolation (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)), so when it does come forward it is most likely the OS/Chromium activating the app on the notification click itself ([discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446)).
302
313
  - System notification banners are drawn on the primary display only: Windows has no setting and Chromium no API to aim one at another monitor, so on a multi-monitor setup the second screen never receives them. Forwarding to your phone with external notifications is the only route across screens (since 1.12.0 the page no longer draws an approval card).
303
- - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. **The Desktop (`dsh-app://`) is tested and cannot register a Service Worker**, so buttons never render there and approval toasts fall back to their buttonless form. The `bridge` field of `window.__dshNotifyMe.debug()` says why; everything else keeps working.
314
+ - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. **The Desktop (`dsh-app://`) is tested and cannot register a Service Worker** (the shell does not grant the `allowServiceWorkers` privilege), so buttons never render there and approval toasts fall back to their buttonless form. The `bridge` field of `window.__dshNotifyMe.debug()` says why; everything else keeps working.
304
315
  - macOS: in the browser, alerts, sounds, the title marker and toast-click navigation carry no OS assumptions and work as on Windows. Two caveats: DSH Desktop (Electron) notifications require the app to be code-signed — unsigned builds simply emit no notification — and whether the official macOS build satisfies that is untested; and notification buttons on the macOS desktop host are likewise untested (the Windows desktop host is tested and renders none, see the line above), falling back to the buttonless form above.
305
316
  - Config lives in browser `localStorage`: switching browsers/devices or clearing site data returns to defaults (one-click restore in the Settings page).
306
317
  - On `>= 0.1.6` (including `0.2.0-rc.2`, `0.2.1-rc.2`) the "reply finished" toast body carries only the session label, no assistant snippet: host snapshots no longer expose `nodes`. The alert itself still fires.
307
318
  - The title marker is written into `document.title` alongside the host: when the host recomputes the title it can overwrite the marker until the next alert event rewrites it.
308
- - "Stay quiet for the conversation on screen" is judged by `document.hidden`: a window fully covered by other apps still counts as visible, so waits in the current conversation stay quiet there too.
309
- - External notification forwarding is subject to browser CORS rules: an endpoint that does not allow cross-origin requests falls back to a "sent but unverifiable" delivery (no-cors) and is reported honestly as such; Discord webhooks do allow cross-origin, so delivery to them is confirmed. HTTP errors are recorded as failures and never blindly retried. Forwarding runs only after the local alert has decided to fire, so anything the rules silence is never sent out.
319
+ - "Stay quiet for the conversation on screen" is judged by whether the window has focus: a window covered by another app, a switched-away app, or a minimized one all count as background, so waits in the current conversation alert there too; once the window is back in front, or you touch the keyboard / mouse again (no longer idle), it goes quiet again.
320
+ - External notification forwarding is subject to browser CORS rules: an endpoint that does not allow cross-origin requests falls back to a "sent but unverifiable" delivery (no-cors) and is reported honestly as such; Discord, Feishu, ServerChan, and WxPusher all allow cross-origin, so delivery to them is confirmed. DingTalk allows no CORS header at all and only accepts `application/json`, so a page cannot post to it — hence no DingTalk target type (relay it through your own box and use a custom webhook). A failure that looks transient (network error, 5xx, 429) is retried on the backoff; a delivery that still fails lands in the failure log, and anything else stops at once. Forwarding runs only after the local alert has decided to fire, so anything the rules silence is never sent out.
310
321
 
311
322
  - **The settings sidebar glyph relies on a DOM patch**: the shell hard-codes icons per section id and `settings.section` exposes no icon field, so the plugin can only swap the `<svg>` in its own row after the panel renders. It depends on the current panel structure (`data-shortcut-modal="settings"` plus a label `<span>` inside each nav button); if DSH changes that markup the patch silently stops working and the default gear shows instead — nothing else is affected.
312
323
 
@@ -332,6 +343,7 @@ Contributors (by handle; the link points at the PR / issue they worked on):
332
343
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10): the per-kind alert sound source (built-in synth / picked audio file / system notification sound, chosen independently for "needs you" and "reply done"), with its settings UI, fallback chain, tests and docs; the two refinements — a "system" preview sending a real test notification instead of doing nothing, and the fake React holding hook state by index across renders — come from the same PR.
333
344
  - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14): the 482-session idle-page CPU report (pinning `currentSessionId()` rescanning the whole table per row, 5.9%~6.5% of CPU) and the fix (derive the on-screen session once per list change), with a red-then-green regression assertion — a 4-row and a 40-row list change must cost the same (upstream reported 7 vs 43, 5 vs 5 after).
334
345
  - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12): the external-notification forwarding suggestion (Discord Webhook / Bark / Gotify / custom webhook) and its use case (1.7.0).
346
+ - [@yuexian7](https://github.com/yuexian7) — [#17](https://github.com/chromoany/dsh-notify-me/issues/17): the ask for alerts on an Android phone — Discord is awkward in mainland China and Bark has no Android app, so a self-hosted relay was the only option; 1.13.0's Feishu / ServerChan / WxPusher targets, retries, and failure log came out of it.
335
347
  - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16): counting "in front but walked away" as away (`idleMinutes`, 3 minutes by default), deciding "reply finished" alerts by which conversation finished (`donePolicy`, three choices), the three-way toast self-dismiss policy (`autoDismiss`), with matching smoke cases and old-config migration; when it landed in 1.12.0 the maintainer added the idle-expiry replay for suppressed approvals, removed the leftover in-page approval card, and brought docs and version up to date.
336
348
 
337
349
  Attribution policy: external PRs are landed as maintainer commits — the original commit is not reused and no Co-authored-by trailer is added, so GitHub's contributors graph lists the maintainer only and contributors are credited above.
package/README.md CHANGED
@@ -26,11 +26,11 @@ DSH Web / Desktop 的通知插件,让你敢不开完整访问,也不必一
26
26
  - **需要你操作时**:审批、方案确认、提问,用通知、提示音和标题标记提醒。
27
27
  - **后台回复完成时**:及时回来查看结果,点击通知可回到对应对话;提醒时机可选「仅后台 / 仅当前对话静默 / 总是提醒」。
28
28
  - **人走开但窗口没走**:窗口留在最前台、人却离开座位,超过 3 分钟没有输入也算离开,该提醒的照常提醒;收到的那条通知不会在你回来之前自己消失。
29
- - **离开电脑时**:可选转发到 Discord、Bark、Gotify 或自己的 Webhook。
29
+ - **离开电脑时**:可选转发到 Discord、Bark、Gotify、飞书群机器人、Server酱、WxPusher 或自己的 Webhook。
30
30
 
31
31
  > 页面或桌面应用需保持运行,后台 / 最小化可以。系统通知需要通知权限。审批按钮靠 Service Worker 才能进通知:Web 端(`http://127.0.0.1` 或 `https`)有,桌面端 `dsh-app://` 页面注册不了,通知上没有按钮,点通知回到对话里裁决。
32
32
 
33
- > 🔎 想找这个插件?可以搜:`dsh-notify-me`、**消息提醒**、**可操作提醒**、**桌面通知**、**需要你操作**、**回复完成提醒**、**后台完成提醒**、**外部通知**、**Discord / Bark / Gotify 推送**(npm 关键词已含以上中英文词)。
33
+ > 🔎 想找这个插件?可以搜:`dsh-notify-me`、**消息提醒**、**可操作提醒**、**桌面通知**、**需要你操作**、**回复完成提醒**、**后台完成提醒**、**外部通知**、**安卓推送**、**Discord / Bark / Gotify / 飞书 / Server酱 / WxPusher 推送**(npm 关键词已含以上中英文词)。
34
34
 
35
35
  ## 效果预览
36
36
 
@@ -73,14 +73,14 @@ dsh plugin --profile web add dsh-notify-me
73
73
  2. 打开 **设置 → 通知提醒**,确认出现该分区及底部插件版本号,保持「启用提醒」「提示音」开启。
74
74
  3. 点击设置页的「测试『回复完成』」按钮:应出现一条测试通知,并按当前音效设置播放声音。这个本地测试不会转发到外部通知端点,也不受「窗口在前台时也提醒」开关限制。
75
75
 
76
- Web 用户可再测试审批按钮。桌面端拿不到 Service Worker,这条测试会在窗口角落画一张带「同意 / 拒绝」的卡片(1.10.4 起;之前的版本这里只发普通通知)。
76
+ Web 用户可再测审批按钮,通知上会带「同意 / 拒绝」。桌面端拿不到 Service Worker,这条测试只发一条普通通知;真实审批的提醒同理——只弹通知,点它回到对话里裁决。
77
77
 
78
78
  **控制台验证(可选)** — 支持 DevTools 的环境也可执行:
79
79
 
80
80
  ```js
81
81
  window.__dshNotifyMe.test("done") // 「完成」示例
82
82
  window.__dshNotifyMe.test("attention") // 「需要你」示例
83
- window.__dshNotifyMe.test("approval") // 桥可用时带「同意 / 拒绝」按钮;桌面端画在窗口角落的卡片上
83
+ window.__dshNotifyMe.test("approval") // 桥可用时带「同意 / 拒绝」按钮;否则只发普通通知(桌面端即如此)
84
84
  window.__dshNotifyMe.debug() // 桥接状态看 bridge 字段,active = 按钮可用
85
85
  ```
86
86
 
@@ -114,7 +114,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
114
114
 
115
115
  - **启用提醒** 总开关:关闭后不弹通知、不响提示音,也不改标签页标题;
116
116
  - **提示音 / 音量**:开关直接切;音量滑块收在该行里,点一下标题(▸ 变成 ▾)展开。具体播什么由「高级设置 → 音效来源」决定;
117
- - **审批通知上直接裁决**(默认开):审批带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。Web 走带按钮的系统通知;桌面端 `dsh-app://` 注册不了 Service Worker,通知上没有按钮,点通知回到对话里裁决。设置页那行下面会如实说明当前环境能不能给按钮。
117
+ - **审批通知上直接裁决**(默认开):审批带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。Web 走带按钮的系统通知;桌面端 `dsh-app://` 注册不了 Service Worker,通知上没有按钮,点通知回到对话里裁决。设置页「试一试」下面那行会如实说明当前环境能不能给按钮。
118
118
 
119
119
  ### 什么时候提醒
120
120
 
@@ -148,12 +148,17 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
148
148
 
149
149
  ### 外部推送
150
150
 
151
- 默认关闭。支持 Discord Webhook / Bark / Gotify / 自定义 Webhook;提醒正文会发往你填写的地址,只使用可信端点。
151
+ 默认关闭。支持 Discord Webhook / Bark / Gotify / 飞书群机器人 / Server酱(Turbo 与 Server酱³)/ WxPusher / 自定义 Webhook;提醒正文会发往你填写的地址,只使用可信端点。
152
152
 
153
153
  <details>
154
- <summary>目标地址、转发开关与发送测试</summary>
154
+ <summary>目标地址、转发开关、发送测试与失败记录</summary>
155
155
 
156
- - **外部通知**(默认关):把提醒同时转发到你自己的推送端点,人不在电脑前也能收到——支持 **Discord Webhook** / **Bark** / **Gotify** / **自定义 Webhook**,可加多个目标,每个选类型填地址(Discord 填 Webhook 地址;Bark 填 `https://api.day.app/<设备密钥>`;Gotify 填 `https://<服务器>/message?token=<应用令牌>`;自定义端点收一条 POST JSON:`{source, event, title, body, sessionId, time}`);「转发「需要你」」「转发「回复完成」」各自独立开关;**发送测试通知**往所有目标发一条测试消息(不受这两类开关限制,也不会冒充真提醒)。转发跟本地提醒走同一套触发规则(「当前对话不弹通知」等开关同样管它);提醒正文会原样发往你填的地址,**只填你自己信任的端点**;
156
+ - **外部通知**(默认关):把提醒同时转发到你自己的推送端点,人不在电脑前也能收到——支持 **Discord Webhook** / **Bark** / **Gotify** / **飞书群机器人** / **Server酱** / **WxPusher** / **自定义 Webhook**,可加多个目标,每个目标可单独启用/停用(Discord 填 Webhook 地址;Bark 填 `https://api.day.app/<设备密钥>`;Gotify 填 `https://<服务器>/message?token=<应用令牌>`;飞书填群机器人 Webhook,可选填签名校验密钥;Server酱 填 `…/send/<SendKey>.send` 完整地址;WxPusher 填 appToken 与接收者 UID;自定义端点收一条 POST JSON:`{source, event, title, body, text, sessionId, time}`);「转发「需要你」」「转发「回复完成」」各自独立开关;**发送测试通知**往所有目标发一条测试消息(不受这两类开关限制,也不会冒充真提醒)。转发跟本地提醒走同一套触发规则(「当前对话不弹通知」等开关同样管它);提醒正文会原样发往你填的地址,**只填你自己信任的端点**;
157
+ - **可靠投递**:明显是临时故障的失败(网络中断、5xx、429)按 2 秒 / 6 秒自动重试两次,其它错误立即停;重试仍失败会把这一条记进「失败记录」(最多 10 条,只留标题与正文摘要和主机名,不留完整端点),设置页能一眼看到并清空;
158
+ - **每个目标的状态**:目标行右侧显示它最近一次投递的结果(上次成功 / 上次已送出但不可确认 / 上次失败 + 原因),并各自带一个启停开关——某个端点临时不可用就单独关掉它,不用关掉整类事件;
159
+ - **签名**:自定义目标填了签名密钥时,请求带 `X-DSH-Notify-Signature`(对请求体做 HMAC-SHA256,`sha256=<hex>`);飞书填了签名密钥时按官方算法在请求体里带 `timestamp` / `sign`;
160
+ - **消息模板**(可选):飞书 / Server酱 / WxPusher / 自定义目标可填模板,占位符 `{{title}}` `{{body}}` `{{event}}` `{{time}}` `{{session}}`,留空默认是「标题 + 换行 + 正文」;
161
+ - **钉钉不在支持列表里**:钉钉群机器人接口不返回任何 CORS 头,且只接受 `application/json`——浏览器页面既读不到响应,也无法发出符合要求的请求(no-cors 下 Content-Type 会被限制成 safelisted 类型)。要用钉钉,请在自己能控制的机器上搭一个中转,再把「自定义 Webhook」指向它。
157
162
 
158
163
  </details>
159
164
 
@@ -179,7 +184,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
179
184
  - **系统通知**:经浏览器弹出的系统通知,进 Windows 通知中心或 macOS 通知中心(点击回到提醒所属的对话;支持快捷裁决的 Web 环境可点审批按钮直接裁决,桌面端没有按钮)
180
185
  - **提示音**:默认是 WebAudio 合成音(「需要你」与「完成」使用不同音型);可在设置里为两类提醒分别换成自定义音频文件,或改成让操作系统播放它自己的通知音
181
186
  - **标签页标题标记**:有待处理事项时,标题前出现 `🔔 需要你 · …` / `🔔 Action needed · …`;后台会话**完成但还没看**时出现 `✅ 回复完成 · …` / `✅ Reply finished · …`,打开对应会话(或它再次开跑)即消失。两种标记可同时出现,「需要你」在前;宿主改写标题后标记基于新标题重建
182
- - **外部通知**(可选,默认关):按设置里配的推送目标,把同一条提醒转发到 Discord Webhook / Bark / Gotify / 自定义 Webhook——临时离开电脑时,手机上的推送渠道照样收到
187
+ - **外部通知**(可选,默认关):按设置里配的推送目标,把同一条提醒转发到 Discord Webhook / Bark / Gotify / 飞书群机器人 / Server酱 / WxPusher / 自定义 Webhook——临时离开电脑时,手机上的推送渠道照样收到
183
188
 
184
189
  本插件是**浏览器层**实现:DSH 页面需保持打开(最小化/后台即可——那正是它监听的「离开」状态)。
185
190
 
@@ -237,8 +242,13 @@ window.__dshNotifyMe.setConfig({
237
242
  externalEnabled: false, // 外部通知转发主开关(默认关:不填端点就什么都不发)
238
243
  externalAttention: true, // 转发「需要你」(触发规则跟本地提醒完全一致)
239
244
  externalDone: true, // 转发「回复完成」
240
- externalTargets: [] // 推送目标:[{id, type:'discord'|'bark'|'gotify'|'custom', url}]
245
+ externalRetryDelays: [2000, 6000], // 可重试失败的退避间隔(毫秒,带抖动;[] = 只发一次)
246
+ externalTargets: [] // 推送目标,见下方字段说明
241
247
  })
248
+ // 目标的形状:{id, type, url, secret?, token?, uid?, template?, enabled?}
249
+ // type: 'discord' | 'bark' | 'gotify' | 'feishu' | 'serverchan' | 'wxpusher' | 'custom'
250
+ // token/uid 只用于 wxpusher(url 固定为官方端点);secret 用于飞书签名与自定义 HMAC 签名;
251
+ // template 可选,占位符 {{title}} {{body}} {{event}} {{time}} {{session}};enabled:false 单独停用该目标
242
252
  window.__dshNotifyMe.resetConfig() // 恢复默认(同时清掉已选的自定义音效文件)
243
253
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // 程序化裁决:"allowed-once" | "rejected"
244
254
  window.__dshNotifyMe.testExternal() // 显式外发测试:向有效目标发送,返回 {sent, unverified, failed, statuses}
@@ -296,13 +306,13 @@ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的
296
306
  - 系统通知横幅只画在主屏:Windows 没有设置项、Chromium 没有 API 能把它指到别的显示器(多屏时副屏收不到系统通知)。要跨屏提醒只能靠外部推送转到手机(1.12.0 起页面里不再画审批卡片)。
297
307
  - 每次页面加载后第一次出声/弹通知前,需在页面上点击过一次(浏览器自动播放与权限策略)。
298
308
  - 未授权通知权限时只有提示音与标题标记。
299
- - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;**桌面端(`dsh-app://`)已实测无法注册 Service Worker**,按钮渲染不了,审批通知退回无按钮样式。浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,其余功能不受影响。
309
+ - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;**桌面端(`dsh-app://`)已实测无法注册 Service Worker**(宿主未开放 `allowServiceWorkers` 特权),按钮渲染不了,审批通知退回无按钮样式。浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,其余功能不受影响。
300
310
  - macOS:浏览器里提醒、提示音、标题标记、点通知跳转都没有平台假设,照常工作;但 DSH Desktop(Electron)的系统通知要求应用已签名,未签名的通知直接发不出去,官方 macOS 版是否满足这一前提未实测;审批按钮在 macOS 桌面宿主上的渲染同样未实测(Windows 桌面端已实测不渲染,见上条),不渲染时自动退回无按钮通知。
301
311
  - 配置存在浏览器 `localStorage`:换浏览器/设备或清除站点数据后会回到默认值(设置页可一键恢复默认)。
302
312
  - `≥ 0.1.6`(含 `0.2.0-rc.2`、`0.2.1-rc.2`)的「回复完成」提醒正文只有会话名,不再附带回复摘要——宿主快照已不再提供 `nodes`;提醒本身照常触发。
303
313
  - 标题标记由宿主与插件共同写 `document.title`:宿主重算标题时标记可能被覆盖,直到下一次提醒事件重新写成。
304
- - 「当前对话不弹通知」按「窗口是否在前台」判定:窗口被别的应用盖住、切到别的应用、最小化都算后台,此时当前对话的等待照常提醒;窗口回到前台再静默。
305
- - 外部通知转发受浏览器跨域(CORS)限制:端点不放行时自动降级为「送出但不可确认」(no-cors),如实标为「已送出不可确认」;Discord Webhook 官方放行跨域,可直接确认送达。HTTP 错误如实记失败、不重试。转发在本地提醒决定要发之后才触发,所以被「当前对话不弹通知」等规则静音的提醒也不会外发。
314
+ - 「当前对话不弹通知」按「窗口是否在前台」判定:窗口被别的应用盖住、切到别的应用、最小化都算后台,此时当前对话的等待照常提醒;窗口回到前台、或重新有键鼠输入(不再闲置)之后,再恢复静默。
315
+ - 外部通知转发受浏览器跨域(CORS)限制:端点不放行时自动降级为「送出但不可确认」(no-cors),如实标为「已送出不可确认」;Discord、飞书、Server酱、WxPusher 都放行跨域,可直接确认送达。钉钉不放行任何 CORS 头、又只收 `application/json`,页面发不出去,所以没有做成目标类型(自建中转 + 自定义目标才行)。明显临时的失败(网络中断、5xx、429)会按退避重试,重试仍失败进失败记录;其余错误立即停、不重试。转发在本地提醒决定要发之后才触发,所以被「当前对话不弹通知」等规则静音的提醒也不会外发。
306
316
 
307
317
  - **设置左侧导航行图标靠 DOM 补丁**:外壳按分区 id 硬编码图标且 `settings.section` 没有图标字段,插件只能在面板渲染后把那一行的 `<svg>` 换成铃铛。命中依赖宿主当前的面板结构(`data-shortcut-modal="settings"` + 导航按钮里的标签 `<span>`);如果 DSH 改了这套结构,补丁会静默失效、显示回默认齿轮,其他功能不受影响。
308
318
 
@@ -328,6 +338,7 @@ npm pack --dry-run # 预览发布包
328
338
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10):「音效来源」功能(内置合成音 / 自定义音频文件 / 系统通知音,「需要你」与「回复完成」各自独立选),含设置页交互、回落链与测试、文档;「系统通知音」试听改发测试通知、假 React 按索引跨渲染保持 hook 状态这两处修正同样出自该 PR。
329
339
  - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14):482 个会话下空闲页面烧 CPU 的性能报告(定位到 `currentSessionId()` 每行重扫全表,实测占 CPU 5.9%~6.5%)与修复(一次列表变化只推导一次「当前会话」),并带先红后绿的回归断言——4 行与 40 行的列表变化代价必须相等(上游报 7 vs 43,修后 5 vs 5)。
330
340
  - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12):外部通知转发(Discord Webhook / Bark / Gotify / 自定义 Webhook)的功能建议与使用场景(1.7.0)。
341
+ - [@yuexian7](https://github.com/yuexian7) — [#17](https://github.com/chromoany/dsh-notify-me/issues/17):安卓端收消息的诉求——Discord 在国内不便、Bark 没有安卓端,只能自己搭后台中转;1.13.0 的飞书 / Server酱 / WxPusher 三种目标、自动重试与失败记录都是据此做的。
331
342
  - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16):把「人在前台但离开了」并入离开判定(`idleMinutes`,默认 3 分钟)、「回复完成」按哪个对话完成决定是否提醒(`donePolicy` 三选一)、通知自动消失的三档策略(`autoDismiss`),并带上对应冒烟用例与旧配置迁移;1.12.0 落地时维护者补了闲置到期补发被压下的审批、删掉残留的页内审批卡片、补齐文档与版本。
332
343
 
333
344
  署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。
package/lib/client.js CHANGED
@@ -277,13 +277,25 @@
277
277
  // dropped the approval card that outlived it: a second on-page copy next
278
278
  // to the notification is noise.
279
279
  // External forwarding: mirror every alert the plugin actually delivers to
280
- // webhook endpoints (Discord / Bark / Gotify / custom), so a phone or
281
- // another box can tell the user to come back. Off by default — nothing
282
- // leaves the browser until the user fills in an endpoint of their own.
280
+ // webhook endpoints (Discord / Bark / Gotify / Feishu / ServerChan /
281
+ // WxPusher / custom), so a phone or another box can tell the user to come
282
+ // back. Off by default — nothing leaves the browser until the user fills
283
+ // in an endpoint of their own.
284
+ // DingTalk is deliberately absent: its robot endpoint answers no CORS
285
+ // header at all and only accepts application/json, so a page cannot send
286
+ // to it (a no-cors POST loses the content type). A relay on the user's
287
+ // own box, wired as a custom target, is the only route.
283
288
  externalEnabled: false,
284
289
  externalAttention: true, // forward the "needs you" alerts
285
290
  externalDone: true, // forward the "reply finished" alerts
286
- // [{ id: string, type: 'discord' | 'bark' | 'gotify' | 'custom', url: string }]
291
+ // Backoff between delivery attempts. Failed attempts are retried on this
292
+ // schedule (with jitter) when the failure looks transient (network error,
293
+ // HTTP 5xx, 429); a non-retryable failure stops at once. An empty list
294
+ // means "send once".
295
+ externalRetryDelays: [2000, 6000],
296
+ // [{ id, type, url, secret?, token?, uid?, template?, enabled? }]
297
+ // type: 'discord' | 'bark' | 'gotify' | 'feishu'
298
+ // | 'serverchan' | 'wxpusher' | 'custom'
287
299
  externalTargets: []
288
300
  };
289
301
  function readConfig() {
@@ -908,24 +920,143 @@
908
920
 
909
921
  // ───────────────────── external webhook forwarding ─────────────────────
910
922
  // Every alert fire() actually delivers is mirrored to the user's webhook
911
- // targets (Discord / Bark / Gotify / custom), so being away from the
912
- // machine does not mean missing the "come back" cue. Off by default:
913
- // nothing leaves the browser until the user fills in an endpoint.
923
+ // targets (Discord / Bark / Gotify / Feishu / ServerChan / WxPusher /
924
+ // custom), so being away from the machine does not mean missing the "come
925
+ // back" cue. Off by default: nothing leaves the browser until the user
926
+ // fills in an endpoint.
914
927
  //
915
928
  // CORS reality: a page can only read a response the endpoint allows it to
916
- // read. Discord webhooks answer with Access-Control-Allow-Origin: * and
917
- // work directly; a self-hosted endpoint may not. So each send tries a
918
- // plain CORS POST first and, only when the browser itself refuses the
919
- // exchange (a rejection from fetch — not an HTTP error status, which means
920
- // the server did see the request), retries as a no-cors POST with a
921
- // text/plain content type: the request is still delivered, only the reply
922
- // is unreadable ("unverified"). Bark gets a third chance in the classic
923
- // GET shape `url/<title>/<body>`, which needs no CORS at all. Real HTTP
924
- // errors are reported as failures and never retried blindly.
925
- var externalStat = { sent: 0, unverified: 0, failed: 0, lastAt: null, lastStatus: null };
926
- // Valid, enabled targets only. A stale entry (bad URL, unknown type) is
927
- // skipped rather than breaking the others; the settings page only ever
928
- // writes well-formed entries.
929
+ // read. Discord webhooks and Feishu bots answer with
930
+ // Access-Control-Allow-Origin: *; ServerChan and WxPusher echo the page
931
+ // origin; a self-hosted endpoint may do neither. So each send tries a plain
932
+ // CORS POST first and, only when the browser itself refuses the exchange (a
933
+ // rejection from fetch — not an HTTP error status, which means the server
934
+ // did see the request), retries as a no-cors POST with a text/plain content
935
+ // type: the request is still delivered, only the reply is unreadable
936
+ // ("unverified"). Bark gets a third chance in the classic GET shape
937
+ // `url/<title>/<body>`, which needs no CORS at all. A transient failure
938
+ // (network error, 5xx, 429) is retried on the configured backoff; anything
939
+ // else stops at once, and a final failure lands in the dead-letter log.
940
+ // ── SHA-256 / HMAC-SHA256 ──────────────────────────────────────────
941
+ // Two signing modes need HMAC-SHA256: Feishu's 签名校验 and the custom
942
+ // target's X-DSH-Notify-Signature header. Web Crypto's
943
+ // crypto.subtle only exists in a secure context, and this plugin also runs
944
+ // crypto.subtle only exists in a secure context, and this plugin also runs
945
+ // on plain-http LAN pages and inside the desktop shell, so the digest is
946
+ // implemented here: no dependency, no secure-context requirement, and the
947
+ // smoke suite cross-checks it against Node's crypto module.
948
+ var SHA256_K = [
949
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
950
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
951
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
952
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
953
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
954
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
955
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
956
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
957
+ ];
958
+ function utf8Bytes(s) {
959
+ if (typeof TextEncoder === "function") return new TextEncoder().encode(s);
960
+ var out = [];
961
+ for (var i = 0; i < s.length; i++) {
962
+ var c = s.charCodeAt(i);
963
+ if (c < 0x80) out.push(c);
964
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
965
+ else if (c >= 0xd800 && c <= 0xdbff) {
966
+ var cp = 0x10000 + ((c - 0xd800) << 10) + (s.charCodeAt(++i) - 0xdc00);
967
+ out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
968
+ } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
969
+ }
970
+ return new Uint8Array(out);
971
+ }
972
+ function rotr(x, n) { return (x >>> n) | (x << (32 - n)); }
973
+ function sha256(bytes) {
974
+ var H = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
975
+ var l = bytes.length;
976
+ var total = (((l + 8) >> 6) + 1) << 6;
977
+ var buf = new Uint8Array(total);
978
+ buf.set(bytes);
979
+ buf[l] = 0x80;
980
+ var dv = new DataView(buf.buffer);
981
+ dv.setUint32(total - 8, Math.floor(l / 0x20000000));
982
+ dv.setUint32(total - 4, (l << 3) >>> 0);
983
+ var w = new Array(64);
984
+ for (var off = 0; off < total; off += 64) {
985
+ var i;
986
+ for (i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
987
+ for (i = 16; i < 64; i++) {
988
+ var s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
989
+ var s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
990
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
991
+ }
992
+ var a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], hh = H[7];
993
+ for (i = 0; i < 64; i++) {
994
+ var t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[i] + w[i]) | 0;
995
+ var t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
996
+ hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
997
+ }
998
+ H[0] = (H[0] + a) | 0; H[1] = (H[1] + b) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
999
+ H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + hh) | 0;
1000
+ }
1001
+ var out = new Uint8Array(32);
1002
+ var odv = new DataView(out.buffer);
1003
+ for (var j = 0; j < 8; j++) odv.setUint32(j * 4, H[j] >>> 0);
1004
+ return out;
1005
+ }
1006
+ function hmacSha256(keyStr, msgStr) {
1007
+ var key = utf8Bytes(keyStr);
1008
+ var msg = utf8Bytes(msgStr);
1009
+ if (key.length > 64) key = sha256(key);
1010
+ var k = new Uint8Array(64);
1011
+ k.set(key);
1012
+ var inner = new Uint8Array(64 + msg.length);
1013
+ var outer = new Uint8Array(64 + 32);
1014
+ for (var i = 0; i < 64; i++) {
1015
+ inner[i] = k[i] ^ 0x36;
1016
+ outer[i] = k[i] ^ 0x5c;
1017
+ }
1018
+ inner.set(msg, 64);
1019
+ outer.set(sha256(inner), 64);
1020
+ return sha256(outer);
1021
+ }
1022
+ function bytesToB64(bytes) {
1023
+ // Hand-rolled instead of btoa(): the page container is not guaranteed to
1024
+ // expose the legacy Web API global, and the signature is the one thing a
1025
+ // user cannot verify by eye.
1026
+ var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1027
+ var s = "";
1028
+ for (var i = 0; i < bytes.length; i += 3) {
1029
+ var b0 = bytes[i];
1030
+ var b1 = i + 1 < bytes.length ? bytes[i + 1] : -1;
1031
+ var b2 = i + 2 < bytes.length ? bytes[i + 2] : -1;
1032
+ s += B64.charAt(b0 >> 2);
1033
+ s += B64.charAt(((b0 & 3) << 4) | (b1 < 0 ? 0 : b1 >> 4));
1034
+ s += b1 < 0 ? "=" : B64.charAt(((b1 & 15) << 2) | (b2 < 0 ? 0 : b2 >> 6));
1035
+ s += b2 < 0 ? "=" : B64.charAt(b2 & 63);
1036
+ }
1037
+ return s;
1038
+ }
1039
+ function bytesToHex(bytes) {
1040
+ var s = "";
1041
+ for (var i = 0; i < bytes.length; i++) s += (bytes[i] < 16 ? "0" : "") + bytes[i].toString(16);
1042
+ return s;
1043
+ }
1044
+
1045
+ var externalStat = { sent: 0, unverified: 0, failed: 0, retried: 0, lastAt: null, lastStatus: null };
1046
+ var LS_EXTERNAL_DEAD = "dshNotifyMe.external.dead";
1047
+ var LS_EXTERNAL_HEALTH = "dshNotifyMe.external.health";
1048
+ var EXTERNAL_DEAD_MAX = 10;
1049
+ var WXPUSHER_ENDPOINT = "https://wxpusher.zjiecode.com/api/send/message";
1050
+ // Unknown / retired type ids degrade to the generic JSON shape instead of
1051
+ // dropping the target: an old profile keeps delivering after an upgrade.
1052
+ function externalType(t) {
1053
+ if (t === "discord" || t === "bark" || t === "gotify" || t === "feishu" ||
1054
+ t === "serverchan" || t === "wxpusher") return t;
1055
+ return "custom";
1056
+ }
1057
+ // Valid, enabled targets only. A stale entry (bad URL, missing token,
1058
+ // unknown type) is skipped rather than breaking the others; the settings
1059
+ // page only ever writes well-formed entries.
929
1060
  function externalTargets() {
930
1061
  var out = [];
931
1062
  var list = config.externalTargets;
@@ -933,50 +1064,159 @@
933
1064
  for (var i = 0; i < list.length; i++) {
934
1065
  var t = list[i];
935
1066
  if (!t || typeof t !== "object" || t.enabled === false) continue;
1067
+ var type = externalType(t.type);
936
1068
  var url = typeof t.url === "string" ? t.url.trim() : "";
1069
+ var secret = typeof t.secret === "string" ? t.secret.trim() : "";
1070
+ var template = typeof t.template === "string" ? t.template : "";
1071
+ var id = typeof t.id === "string" ? t.id : "";
1072
+ if (type === "wxpusher") {
1073
+ // The appToken and the receiver id are the whole configuration; the
1074
+ // endpoint itself is fixed, so there is no URL to get wrong.
1075
+ var token = typeof t.token === "string" ? t.token.trim() : "";
1076
+ var uid = typeof t.uid === "string" ? t.uid.trim() : "";
1077
+ if (!token || !uid) continue;
1078
+ out.push({ id: id, type: type, url: WXPUSHER_ENDPOINT, secret: "", template: template, token: token, uid: uid });
1079
+ continue;
1080
+ }
937
1081
  if (!/^https?:\/\//i.test(url)) continue;
938
- var type = t.type;
939
- if (type !== "discord" && type !== "bark" && type !== "gotify") type = "custom";
940
- out.push({ id: typeof t.id === "string" ? t.id : "", type: type, url: url });
1082
+ out.push({ id: id, type: type, url: url, secret: secret, template: template, token: "", uid: "" });
941
1083
  }
942
1084
  return out;
943
1085
  }
944
- // Per-endpoint JSON body, each shaped by that service's own convention;
945
- // the custom type gets the full generic event so any JSON endpoint works.
946
- function externalBody(type, ev) {
947
- if (type === "discord") {
948
- var content = ev.title + "\n" + ev.body;
949
- if (content.length > 1900) content = content.slice(0, 1900) + "…";
950
- return JSON.stringify({ username: "DSH", content: content });
1086
+ // The text every chat-style target shows. A target may pin its own template
1087
+ // ({{title}} / {{body}} / {{event}} / {{time}} / {{session}}); without one
1088
+ // the alert reads "title" then the body, which is what the previous
1089
+ // version sent as one string.
1090
+ function externalContent(target, ev) {
1091
+ if (!target.template) return ev.title + "\n" + ev.body;
1092
+ return target.template.replace(/\{\{\s*(title|body|event|time|session)\s*\}\}/g, function (m, key) {
1093
+ if (key === "title") return ev.title;
1094
+ if (key === "body") return ev.body;
1095
+ if (key === "event") return ev.kind;
1096
+ if (key === "session") return ev.sessionId || "";
1097
+ return ev.time;
1098
+ });
1099
+ }
1100
+ // Per-endpoint request, each shaped by that service's own convention.
1101
+ // `noCorsType` is the content type used for the opaque fallback: only the
1102
+ // three CORS-safelisted types may be set on a no-cors request, so a JSON
1103
+ // payload falls back to text/plain and a form stays a form.
1104
+ function externalRequest(target, ev) {
1105
+ var text = externalContent(target, ev);
1106
+ var jsonType = "application/json";
1107
+ var plainType = "text/plain;charset=UTF-8";
1108
+ var formType = "application/x-www-form-urlencoded;charset=UTF-8";
1109
+ var req;
1110
+ if (target.type === "discord") {
1111
+ req = { url: target.url, contentType: jsonType, noCorsType: plainType, headers: {}, body: JSON.stringify({ username: "DSH", content: text.length > 1900 ? text.slice(0, 1900) + "…" : text }) };
1112
+ return { type: "discord", req: req };
951
1113
  }
952
- if (type === "bark") {
953
- return JSON.stringify({ title: ev.title, body: ev.body, group: "dsh-notify-me" });
1114
+ if (target.type === "bark") {
1115
+ return { type: "bark", req: { url: target.url, contentType: jsonType, noCorsType: plainType, headers: {}, body: JSON.stringify({ title: ev.title, body: text, group: "dsh-notify-me" }) } };
954
1116
  }
955
- if (type === "gotify") {
956
- return JSON.stringify({ title: ev.title, message: ev.body, priority: ev.kind === "attention" ? 8 : 5 });
1117
+ if (target.type === "gotify") {
1118
+ return { type: "gotify", req: { url: target.url, contentType: jsonType, noCorsType: plainType, headers: {}, body: JSON.stringify({ title: ev.title, message: text, priority: ev.kind === "attention" ? 8 : 5 }) } };
957
1119
  }
958
- return JSON.stringify({
959
- source: "dsh-notify-me",
960
- version: api.version,
961
- event: ev.kind,
962
- title: ev.title,
963
- body: ev.body,
964
- sessionId: ev.sessionId || null,
965
- time: ev.time
966
- });
967
- }
968
- // Resolves to 'sent' | 'unverified' | 'failed (…)'; never rejects.
969
- function postOnce(url, bodyStr, plain) {
970
- return fetch(url, {
1120
+ if (target.type === "feishu") {
1121
+ // Feishu's signature is `base64(HMAC-SHA256(key = timestamp + "\n" +
1122
+ // secret, data = ""))` with the timestamp in seconds, carried inside
1123
+ // the JSON body (which Feishu's endpoint does expose to the browser).
1124
+ var fb = { msg_type: "text", content: { text: text } };
1125
+ if (target.secret) {
1126
+ var fts = String(Math.floor(Date.now() / 1000));
1127
+ fb.timestamp = fts;
1128
+ fb.sign = bytesToB64(hmacSha256(fts + "\n" + target.secret, ""));
1129
+ }
1130
+ return { type: "feishu", req: { url: target.url, contentType: jsonType, noCorsType: plainType, headers: {}, body: JSON.stringify(fb) } };
1131
+ }
1132
+ if (target.type === "serverchan") {
1133
+ // ServerChan (Turbo and ³ share this ABI) takes a form: title + desp,
1134
+ // and answers {"code":0}. A form content type is CORS-safelisted, so
1135
+ // the opaque fallback carries the same body unchanged. Turbo truncates
1136
+ // the title at 32 chars and refuses a newline in it.
1137
+ var sctTitle = String(ev.title || "").replace(/\s+/g, " ").slice(0, 32);
1138
+ return { type: "serverchan", req: { url: target.url, contentType: formType, noCorsType: formType, headers: {}, body: "title=" + encodeURIComponent(sctTitle) + "&desp=" + encodeURIComponent(text) } };
1139
+ }
1140
+ if (target.type === "wxpusher") {
1141
+ var uids = target.uid.split(/[,;\s]+/).filter(function (s) { return !!s; });
1142
+ return { type: "wxpusher", req: { url: target.url, contentType: jsonType, noCorsType: plainType, headers: {}, body: JSON.stringify({ appToken: target.token, content: text, summary: ev.title, contentType: 1, uids: uids }) } };
1143
+ }
1144
+ // custom: the generic event plus the rendered text, so a plain endpoint
1145
+ // (or a chat relay) can use either the fields or the text.
1146
+ req = {
1147
+ url: target.url,
1148
+ contentType: jsonType,
1149
+ noCorsType: plainType,
1150
+ headers: {},
1151
+ body: JSON.stringify({
1152
+ source: "dsh-notify-me",
1153
+ version: api.version,
1154
+ event: ev.kind,
1155
+ title: ev.title,
1156
+ body: ev.body,
1157
+ text: text,
1158
+ sessionId: ev.sessionId || null,
1159
+ time: ev.time
1160
+ })
1161
+ };
1162
+ if (target.secret) {
1163
+ // Outbound proof that the request came from this browser: the receiver
1164
+ // recomputes HMAC-SHA256(secret, raw body) and compares. Custom headers
1165
+ // make the request preflighted, so this only works on an endpoint that
1166
+ // answers OPTIONS — the opaque fallback cannot carry it.
1167
+ req.headers["X-DSH-Notify-Signature"] = "sha256=" + bytesToHex(hmacSha256(target.secret, req.body));
1168
+ req.headers["X-DSH-Notify-Timestamp"] = ev.time;
1169
+ }
1170
+ return { type: "custom", req: req };
1171
+ }
1172
+ // HTTP status / service code -> {status, retry}. Only transient failures are
1173
+ // worth another attempt; a wrong token or a malformed endpoint is not.
1174
+ function externalVerdict(type, res, bodyText) {
1175
+ if (!res) return { status: "failed (no response)", retry: true };
1176
+ if (!res.ok) {
1177
+ var retry = res.status >= 500 || res.status === 429;
1178
+ return { status: "failed (HTTP " + res.status + ")", retry: retry };
1179
+ }
1180
+ var parsed = null;
1181
+ if (bodyText) { try { parsed = JSON.parse(bodyText); } catch (e) { parsed = null; } }
1182
+ if (type === "feishu" || type === "serverchan" || type === "wxpusher") {
1183
+ // These answer 200 with a service code; a wrong token shows up there,
1184
+ // not in the HTTP status. DingTalk does the same but cannot be reached
1185
+ // from a page at all, hence no branch for it.
1186
+ if (!parsed || typeof parsed !== "object") return { status: "sent", retry: false };
1187
+ var code = parsed.code;
1188
+ var okCode = type === "wxpusher" ? 1000 : 0;
1189
+ if (code === okCode) return { status: "sent", retry: false };
1190
+ var msg = parsed.error || parsed.message || parsed.info || parsed.msg || ("code " + code);
1191
+ return { status: "failed (" + String(msg).slice(0, 120) + ")", retry: false };
1192
+ }
1193
+ return { status: "sent", retry: false };
1194
+ }
1195
+ // One attempt. Resolves to {status, retry}; never rejects.
1196
+ function postOnce(spec, plain) {
1197
+ var headers = {};
1198
+ headers["Content-Type"] = plain ? spec.noCorsType : spec.contentType;
1199
+ if (!plain) {
1200
+ for (var k in spec.headers) {
1201
+ if (Object.prototype.hasOwnProperty.call(spec.headers, k)) headers[k] = spec.headers[k];
1202
+ }
1203
+ }
1204
+ return fetch(spec.url, {
971
1205
  method: "POST",
972
1206
  mode: plain ? "no-cors" : "cors",
973
1207
  cache: "no-store",
974
- headers: { "Content-Type": plain ? "text/plain;charset=UTF-8" : "application/json" },
975
- body: bodyStr
1208
+ headers: headers,
1209
+ body: spec.body
976
1210
  }).then(function (res) {
977
- if (plain) return "unverified"; // opaque: delivered, reply unreadable
978
- if (res && res.ok) return "sent";
979
- return "failed (HTTP " + (res ? res.status : "?") + ")";
1211
+ if (plain) return { status: "unverified", retry: false }; // opaque: delivered, reply unreadable
1212
+ // Some hosts (and the smoke harness) hand back a response-like object
1213
+ // without a body reader; the HTTP status is then all there is to judge.
1214
+ if (typeof res.text !== "function") return externalVerdict(spec.type, res, "");
1215
+ return res.text().then(function (text) {
1216
+ return externalVerdict(spec.type, res, text);
1217
+ }, function () {
1218
+ return externalVerdict(spec.type, res, "");
1219
+ });
980
1220
  });
981
1221
  }
982
1222
  // Bark's path form: url/<title>/<body>. A plain GET first (readable), then
@@ -985,27 +1225,115 @@
985
1225
  function barkGet(url, ev) {
986
1226
  var u = url.replace(/\/+$/, "") + "/" + encodeURIComponent(ev.title || "DSH") + "/" + encodeURIComponent(ev.body || "");
987
1227
  return fetch(u, { method: "GET", cache: "no-store" }).then(function (res) {
988
- return res && res.ok ? "sent" : Promise.reject(new Error("HTTP " + (res ? res.status : "?")));
1228
+ return res && res.ok ? { status: "sent", retry: false } : Promise.reject(new Error("HTTP " + (res ? res.status : "?")));
989
1229
  }).catch(function () {
990
- return fetch(u, { method: "GET", mode: "no-cors", cache: "no-store" }).then(function () { return "unverified"; });
1230
+ return fetch(u, { method: "GET", mode: "no-cors", cache: "no-store" }).then(function () { return { status: "unverified", retry: false }; });
991
1231
  });
992
1232
  }
993
- function postExternal(target, ev) {
994
- if (typeof fetch !== "function") return Promise.resolve("failed (no fetch)");
995
- var bodyStr = externalBody(target.type, ev);
996
- return postOnce(target.url, bodyStr, false).catch(function (err) {
1233
+ function deliverAttempt(target, ev) {
1234
+ if (typeof fetch !== "function") return Promise.resolve({ status: "failed (no fetch)", retry: false });
1235
+ var built;
1236
+ try { built = externalRequest(target, ev); } catch (e) { return Promise.resolve({ status: "failed (payload)", retry: false }); }
1237
+ var spec = built.req;
1238
+ return postOnce(spec, false).catch(function (err) {
997
1239
  var msg = "failed (" + (err && err.message ? err.message : String(err)) + ")";
998
- return postOnce(target.url, bodyStr, true).then(null, function () {
999
- if (target.type !== "bark") return msg;
1000
- return barkGet(target.url, ev).then(null, function () { return msg; });
1240
+ // The browser refused the exchange (CORS or the network): the opaque
1241
+ // path still gets the request out, only the reply is unreadable.
1242
+ return postOnce(spec, true).catch(function () {
1243
+ if (built.type !== "bark") return { status: msg, retry: true };
1244
+ return barkGet(target.url, ev).then(null, function () { return { status: msg, retry: true }; });
1245
+ });
1246
+ });
1247
+ }
1248
+ function externalRetryDelays() {
1249
+ var d = config.externalRetryDelays;
1250
+ if (!Array.isArray(d)) return [2000, 6000];
1251
+ var out = [];
1252
+ for (var i = 0; i < d.length && out.length < 4; i++) {
1253
+ var v = d[i];
1254
+ if (typeof v === "number" && isFinite(v) && v >= 0) out.push(Math.min(v, 60000));
1255
+ }
1256
+ return out;
1257
+ }
1258
+ function readExternalDead() {
1259
+ try {
1260
+ var raw = localStorage.getItem(LS_EXTERNAL_DEAD);
1261
+ var arr = raw ? JSON.parse(raw) : [];
1262
+ return Array.isArray(arr) ? arr : [];
1263
+ } catch (e) { return []; }
1264
+ }
1265
+ function addExternalDead(entry) {
1266
+ var list = readExternalDead();
1267
+ list.unshift(entry);
1268
+ if (list.length > EXTERNAL_DEAD_MAX) list = list.slice(0, EXTERNAL_DEAD_MAX);
1269
+ try { localStorage.setItem(LS_EXTERNAL_DEAD, JSON.stringify(list)); } catch (e) {}
1270
+ }
1271
+ function clearExternalDead() {
1272
+ try { localStorage.removeItem(LS_EXTERNAL_DEAD); } catch (e) {}
1273
+ }
1274
+ var externalHealth = (function () {
1275
+ try {
1276
+ var raw = localStorage.getItem(LS_EXTERNAL_HEALTH);
1277
+ var o = raw ? JSON.parse(raw) : null;
1278
+ return o && typeof o === "object" ? o : {};
1279
+ } catch (e) { return {}; }
1280
+ })();
1281
+ function recordExternalHealth(id, status, attempts, reason) {
1282
+ if (!id) return;
1283
+ externalHealth[id] = { status: status, at: new Date().toISOString(), attempts: attempts || 1, reason: reason || "" };
1284
+ try {
1285
+ var keys = Object.keys(externalHealth);
1286
+ if (keys.length > 40) for (var i = 0; i < keys.length - 40; i++) delete externalHealth[keys[i]];
1287
+ localStorage.setItem(LS_EXTERNAL_HEALTH, JSON.stringify(externalHealth));
1288
+ } catch (e) {}
1289
+ }
1290
+ function maskExternalUrl(url) {
1291
+ // The path of a webhook is usually the secret itself (Discord, Bark,
1292
+ // Feishu hook id); the host is enough to recognise the target later.
1293
+ // Deliberately not `new URL()`: this also runs in hosts whose page
1294
+ // container lacks the URL global.
1295
+ var m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(String(url || ""));
1296
+ if (!m) return "";
1297
+ return m[1].split("@").pop();
1298
+ }
1299
+ // At-least-once delivery: transient failures are retried on the configured
1300
+ // backoff with jitter (a page full of targets must not come back in
1301
+ // lockstep). A final failure is written to the dead-letter log — the alert
1302
+ // itself was already shown locally, so this log is the only trace that the
1303
+ // phone did not get it.
1304
+ function sendToTarget(target, ev) {
1305
+ var delays = externalRetryDelays();
1306
+ var attempts = 0;
1307
+ function run() {
1308
+ attempts++;
1309
+ return deliverAttempt(target, ev).then(function (r) {
1310
+ if (r.status === "sent" || r.status === "unverified") return r;
1311
+ if (!r.retry || attempts > delays.length) return r;
1312
+ var wait = Math.round(delays[attempts - 1] * (1 + Math.random() * 0.25));
1313
+ return new Promise(function (resolve) { setTimeout(function () { resolve(run()); }, wait); });
1001
1314
  });
1002
- }).then(function (status) {
1003
- if (status === "sent") externalStat.sent++;
1004
- else if (status === "unverified") externalStat.unverified++;
1005
- else externalStat.failed++;
1315
+ }
1316
+ return run().then(function (r) {
1317
+ var failed = r.status.indexOf("failed") === 0;
1318
+ recordExternalHealth(target.id, r.status, attempts, failed ? r.status.slice(7) : "");
1319
+ if (failed) {
1320
+ externalStat.failed++;
1321
+ addExternalDead({
1322
+ at: new Date().toISOString(),
1323
+ targetId: target.id || "",
1324
+ type: target.type,
1325
+ host: maskExternalUrl(target.url),
1326
+ title: String(ev.title || "").slice(0, 120),
1327
+ body: String(ev.body || "").slice(0, 280),
1328
+ status: r.status,
1329
+ attempts: attempts
1330
+ });
1331
+ } else if (r.status === "sent") externalStat.sent++;
1332
+ else externalStat.unverified++;
1333
+ if (attempts > 1) externalStat.retried++;
1006
1334
  externalStat.lastAt = new Date().toISOString();
1007
- externalStat.lastStatus = status;
1008
- return status;
1335
+ externalStat.lastStatus = r.status;
1336
+ return r.status;
1009
1337
  });
1010
1338
  }
1011
1339
  // Mirror one delivered alert to every enabled target. The alert path is
@@ -1031,7 +1359,7 @@
1031
1359
  };
1032
1360
  var ps = [];
1033
1361
  for (var i = 0; i < targets.length; i++) {
1034
- try { ps.push(postExternal(targets[i], ev)); } catch (e) { ps.push(Promise.resolve("failed (dispatch)")); }
1362
+ try { ps.push(sendToTarget(targets[i], ev)); } catch (e) { ps.push(Promise.resolve("failed (dispatch)")); }
1035
1363
  }
1036
1364
  if (typeof onDone === "function") Promise.all(ps).then(onDone, function () {});
1037
1365
  }
@@ -1883,7 +2211,7 @@
1883
2211
  });
1884
2212
  } catch (e) {}
1885
2213
  // keep in sync with package.json "version" on every release
1886
- api.version = "1.12.0";
2214
+ api.version = "1.13.0";
1887
2215
  api.setConfig = function (patch) { return setConfigLocal(patch); };
1888
2216
  api.resetConfig = function () {
1889
2217
  try {
@@ -1893,6 +2221,11 @@
1893
2221
  // quota with no UI left to remove them from.
1894
2222
  localStorage.removeItem(LS_SOUND.done);
1895
2223
  localStorage.removeItem(LS_SOUND.attention);
2224
+ // Delivery bookkeeping is user-visible state too: a reset should not
2225
+ // leave a dead-letter log (or stale per-target verdicts) behind.
2226
+ localStorage.removeItem(LS_EXTERNAL_DEAD);
2227
+ localStorage.removeItem(LS_EXTERNAL_HEALTH);
2228
+ externalHealth = {};
1896
2229
  } catch (e) {}
1897
2230
  config = readConfig();
1898
2231
  return config;
@@ -1953,18 +2286,25 @@
1953
2286
  customSoundAttention: !!readSound("attention"),
1954
2287
  quickActions: config.quickActions,
1955
2288
  // external webhook forwarding: counts come from externalStat (tallied
1956
- // on every send), targets is the raw vs validated count.
2289
+ // on every send), targets is the raw vs validated count. `deadLetters`
2290
+ // is the persisted "never made it" log; `health` is the last verdict
2291
+ // per target id, which the settings page renders next to each row.
1957
2292
  external: {
1958
2293
  enabled: config.externalEnabled,
1959
2294
  forwardAttention: config.externalAttention,
1960
2295
  forwardDone: config.externalDone,
1961
2296
  targets: Array.isArray(config.externalTargets) ? config.externalTargets.length : 0,
1962
2297
  activeTargets: externalTargets().length,
2298
+ retryDelays: externalRetryDelays(),
1963
2299
  sent: externalStat.sent,
1964
2300
  unverified: externalStat.unverified,
1965
2301
  failed: externalStat.failed,
2302
+ retried: externalStat.retried,
1966
2303
  lastAt: externalStat.lastAt,
1967
- lastStatus: externalStat.lastStatus
2304
+ lastStatus: externalStat.lastStatus,
2305
+ deadLetters: readExternalDead().length,
2306
+ deadLettersLog: readExternalDead(),
2307
+ health: externalHealth
1968
2308
  },
1969
2309
  // quick-decision bridge: 'active' is the only state in which approval
1970
2310
  // toasts carry working 同意/拒绝 buttons.
@@ -2048,7 +2388,7 @@
2048
2388
  soundOffHint: "「提示音」开关是关的:先打开它才能试听(关掉时提醒本身也确实不出声)。",
2049
2389
  soundPreviewSystem: "「系统通知音」是随通知由系统播放的,插件这边没有可播的音;已发一条测试提醒,声音就是它。",
2050
2390
  external: "外部通知",
2051
- externalHint: "把提醒同时推到 Discord / Bark / Gotify / 自定义 Webhook,人不在电脑前也能收到。与本地提醒同一套触发规则。",
2391
+ externalHint: "把提醒同时推到 Discord / Bark / Gotify / 飞书 / Server酱 / WxPusher / 自定义 Webhook,人不在电脑前也能收到。与本地提醒同一套触发规则;投递失败会按 2 秒 / 6 秒自动重试,仍失败则记进失败记录。",
2052
2392
  externalEnable: "转发外部通知",
2053
2393
  externalEnableHint: "总开关:关闭后不向任何外部端点发送。",
2054
2394
  externalAttentionLabel: "转发「需要你」",
@@ -2056,17 +2396,41 @@
2056
2396
  externalDoneLabel: "转发「回复完成」",
2057
2397
  externalDoneHint: "回复跑完时一并推送到外部端点。",
2058
2398
  externalTargetsLabel: "推送目标",
2059
- externalTargetsHint: "可加多个。Discord 填 Webhook URL;Bark 填 https://api.day.app/<设备密钥>(自建填 http://<服务器>/<密钥>);Gotify 填 https://<服务器>/message?token=<应用令牌>;自定义填任意收 JSON 的地址(POST:source / event / title / body / sessionId / time)。",
2060
- externalPrivacy: "提醒标题与正文(含会话名与摘要)会原样发到你填写的地址——只填你自己信任的端点。",
2399
+ externalTargetsHint: "可加多个,每个目标可单独停用。飞书填群机器人 Webhook;Server酱 填 …/send/<SendKey>.send 完整地址(Turbo 与 Server酱³ 都行);WxPusher 填 appToken 与接收者 UID;Discord 填 Webhook URL;Bark 填 https://api.day.app/<设备密钥>(自建填 http://<服务器>/<密钥>);Gotify 填 https://<服务器>/message?token=<应用令牌>;自定义填任意收 JSON 的地址(POST:source / event / title / body / text / sessionId / time)。钉钉群机器人不在此列:它的接口不回 CORS 头、又只收 application/json,浏览器页面发不出去;要发钉钉请自建一个中转,用「自定义」指向它。",
2400
+ externalPrivacy: "提醒标题与正文(含会话名与摘要)会原样发到你填写的地址——只填你自己信任的端点。自定义目标填了签名密钥时,请求会带 X-DSH-Notify-Signature(对请求体做 HMAC-SHA256);该头会触发 CORS 预检,只对支持预检的接收端有效。飞书填了签名密钥时按官方算法在请求体里带 timestamp/sign。",
2061
2401
  externalTypeDiscord: "Discord Webhook",
2062
2402
  externalTypeBark: "Bark",
2063
2403
  externalTypeGotify: "Gotify",
2404
+ externalTypeFeishu: "飞书群机器人",
2405
+ externalTypeServerchan: "Server酱",
2406
+ externalTypeWxpusher: "WxPusher",
2064
2407
  externalTypeCustom: "自定义 Webhook",
2065
2408
  externalUrlPlaceholder: "https://…",
2066
2409
  externalAdd: "添加目标",
2067
2410
  externalRemove: "删除这个目标",
2068
2411
  externalTypeField: "推送类型",
2069
2412
  externalUrlField: "推送地址",
2413
+ externalTargetToggle: "启用或停用这个目标",
2414
+ externalSecretField: "签名密钥",
2415
+ externalSecretPlaceholderFeishu: "签名校验密钥(可选)",
2416
+ externalSecretPlaceholderCustom: "HMAC 签名密钥(可选,留空不签名)",
2417
+ externalTokenField: "appToken",
2418
+ externalTokenPlaceholder: "AT_…",
2419
+ externalUidField: "接收者 UID",
2420
+ externalUidPlaceholder: "UID_…(多个用逗号分隔)",
2421
+ externalTemplateField: "消息模板",
2422
+ externalTemplatePlaceholder: "留空用默认:{{title}} 换行 {{body}}",
2423
+ externalStatusNever: "尚未投递",
2424
+ externalStatusSent: "上次成功",
2425
+ externalStatusUnverified: "上次已送出(不可确认)",
2426
+ externalStatusFailed: "上次失败",
2427
+ externalDeadLabel: "失败记录",
2428
+ externalDeadNone: "没有投递失败的记录。",
2429
+ externalDeadHint: "彻底失败 ",
2430
+ externalDeadUnit: " 条(最多保留 10 条,只记标题与正文摘要)",
2431
+ externalDeadLast: "最后一条:",
2432
+ externalDeadClear: "清空失败记录",
2433
+ externalDeadCleared: "失败记录已清空。",
2070
2434
  externalEmpty: "还没有推送目标,点「添加目标」加一个。",
2071
2435
  externalTestLabel: "测一测外部通知",
2072
2436
  externalTestHint: "向上面所有推送目标各发一条测试消息(不受「转发」分类开关限制)。",
@@ -2173,7 +2537,7 @@
2173
2537
  soundOffHint: "The Sound switch is off: turn it on to preview (with it off the alerts themselves stay silent too).",
2174
2538
  soundPreviewSystem: "The system sound plays with a toast, not from the plugin — a test alert was sent instead; that sound is it.",
2175
2539
  external: "External notifications",
2176
- externalHint: "Also push alerts to Discord / Bark / Gotify / custom webhooks, so you hear about it away from the machine. Same trigger rules as local alerts.",
2540
+ externalHint: "Also push alerts to Discord / Bark / Gotify / Feishu / ServerChan / WxPusher / custom webhooks, so you hear about it away from the machine. Same trigger rules as local alerts; a failed delivery is retried after 2 s and 6 s and, if it still fails, recorded in the failure log.",
2177
2541
  externalEnable: "Forward to external targets",
2178
2542
  externalEnableHint: "Master switch: when off, nothing is sent to any external endpoint.",
2179
2543
  externalAttentionLabel: "Forward \"needs you\"",
@@ -2181,17 +2545,41 @@
2181
2545
  externalDoneLabel: "Forward \"reply finished\"",
2182
2546
  externalDoneHint: "Push reply-finished alerts to the external endpoints too.",
2183
2547
  externalTargetsLabel: "Push targets",
2184
- externalTargetsHint: "Add as many as you like. Discord: the webhook URL; Bark: https://api.day.app/<device key> (self-hosted: http://<server>/<key>); Gotify: https://<server>/message?token=<app token>; custom: any endpoint accepting JSON (POST: source / event / title / body / sessionId / time).",
2185
- externalPrivacy: "The alert title and body (including the session name and snippet) are sent as-is to the address you enter — only use endpoints you trust.",
2548
+ externalTargetsHint: "Add as many as you like; each one can be disabled on its own. Feishu: the group-bot webhook; ServerChan: the full .../send/<SendKey>.send URL (Turbo and ServerChan³ both work); WxPusher: the appToken and the receiver UID; Discord: the webhook URL; Bark: https://api.day.app/<device key> (self-hosted: http://<server>/<key>); Gotify: https://<server>/message?token=<app token>; custom: any endpoint accepting JSON (POST: source / event / title / body / text / sessionId / time). DingTalk is not on the list: its robot endpoint sends no CORS headers and only accepts application/json, so a page cannot post to it — relay it through your own box and point a custom target at that.",
2549
+ externalPrivacy: "The alert title and body (including the session name and snippet) are sent as-is to the address you enter — only use endpoints you trust. When a custom target has a signing secret, the request carries X-DSH-Notify-Signature (HMAC-SHA256 over the body); that header triggers a CORS preflight, so it only works on a receiver that answers it. A Feishu target with a secret carries timestamp/sign in the body, per Feishu's own algorithm.",
2186
2550
  externalTypeDiscord: "Discord webhook",
2187
2551
  externalTypeBark: "Bark",
2188
2552
  externalTypeGotify: "Gotify",
2553
+ externalTypeFeishu: "Feishu group bot",
2554
+ externalTypeServerchan: "ServerChan",
2555
+ externalTypeWxpusher: "WxPusher",
2189
2556
  externalTypeCustom: "Custom webhook",
2190
2557
  externalUrlPlaceholder: "https://…",
2191
2558
  externalAdd: "Add target",
2192
2559
  externalRemove: "Remove this target",
2193
2560
  externalTypeField: "Target type",
2194
2561
  externalUrlField: "Endpoint URL",
2562
+ externalTargetToggle: "Enable or disable this target",
2563
+ externalSecretField: "Signing secret",
2564
+ externalSecretPlaceholderFeishu: "Verification secret (optional)",
2565
+ externalSecretPlaceholderCustom: "HMAC signing secret (optional; empty = unsigned)",
2566
+ externalTokenField: "appToken",
2567
+ externalTokenPlaceholder: "AT_…",
2568
+ externalUidField: "Receiver UID",
2569
+ externalUidPlaceholder: "UID_… (comma-separated for several)",
2570
+ externalTemplateField: "Message template",
2571
+ externalTemplatePlaceholder: "Empty = default: {{title}} then {{body}}",
2572
+ externalStatusNever: "not sent yet",
2573
+ externalStatusSent: "last delivery ok",
2574
+ externalStatusUnverified: "last delivery sent-unverified",
2575
+ externalStatusFailed: "last delivery failed",
2576
+ externalDeadLabel: "Failure log",
2577
+ externalDeadNone: "No failed deliveries.",
2578
+ externalDeadHint: "Failed ",
2579
+ externalDeadUnit: " time(s) (10 kept at most; title and body snippet only)",
2580
+ externalDeadLast: "Latest: ",
2581
+ externalDeadClear: "Clear the failure log",
2582
+ externalDeadCleared: "Failure log cleared.",
2195
2583
  externalEmpty: "No push targets yet — press \"Add target\" to create one.",
2196
2584
  externalTestLabel: "Test the external channel",
2197
2585
  externalTestHint: "Send one test message to every push target above (the per-kind forward toggles do not apply to this button).",
@@ -2366,6 +2754,12 @@
2366
2754
  ".dnm-file{font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));max-width:200px}" +
2367
2755
  ".dnm-targetRow{display:flex;align-items:center;gap:8px;padding:10px 14px;min-height:46px;box-sizing:border-box;flex-wrap:wrap}" +
2368
2756
  ".dnm-targetRow+.dnm-targetRow{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
2757
+ // A target's secondary fields (signing secret, receiver id, template)
2758
+ // hang under their row, same as dnm-rowSub does for the sound rows.
2759
+ ".dnm-targetRowSub{padding-left:26px;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.04))}" +
2760
+ ".dnm-targetStatus{flex:none;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
2761
+ ".dnm-targetStatus.ok{color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
2762
+ ".dnm-targetStatus.off{opacity:.55}" +
2369
2763
  ".dnm-input{flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:12px;font-family:inherit}" +
2370
2764
  ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
2371
2765
  ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}" +
@@ -2731,31 +3125,72 @@
2731
3125
  // External forwarding rows. Target edits always patch with a fresh array
2732
3126
  // (readConfig may hand out the DEFAULTS entry itself; in-place mutation
2733
3127
  // would silently corrupt the defaults for the next reset).
3128
+ var EXTERNAL_TYPE_OPTIONS = [
3129
+ ["discord", "externalTypeDiscord"],
3130
+ ["bark", "externalTypeBark"],
3131
+ ["gotify", "externalTypeGotify"],
3132
+ ["feishu", "externalTypeFeishu"],
3133
+ ["serverchan", "externalTypeServerchan"],
3134
+ ["wxpusher", "externalTypeWxpusher"],
3135
+ ["custom", "externalTypeCustom"]
3136
+ ];
3137
+ // Last verdict for one target, as plain words. The record is written by
3138
+ // the delivery engine on every send and survives a reload, so a target
3139
+ // that has been silently failing says so where it is configured.
3140
+ function externalStatusText(target) {
3141
+ var rec = target && target.id ? externalHealth[target.id] : null;
3142
+ if (!rec || !rec.at) return t("externalStatusNever");
3143
+ var label = rec.status === "sent" ? t("externalStatusSent")
3144
+ : rec.status === "unverified" ? t("externalStatusUnverified")
3145
+ : t("externalStatusFailed");
3146
+ var when = "";
3147
+ try { when = new Date(rec.at).toLocaleTimeString(); } catch (e) { when = ""; }
3148
+ var why = rec.status === "sent" || rec.status === "unverified" ? "" : (rec.reason ? " · " + rec.reason : "");
3149
+ return label + why + (when ? " · " + when : "");
3150
+ }
3151
+ function targetField(field, i, value, placeholder, label) {
3152
+ return h("input", {
3153
+ className: "dnm-input",
3154
+ value: value || "",
3155
+ placeholder: placeholder,
3156
+ "aria-label": label,
3157
+ onChange: function (e) {
3158
+ var fields = {};
3159
+ fields[field] = e.currentTarget.value;
3160
+ updateTarget(i, fields);
3161
+ }
3162
+ });
3163
+ }
2734
3164
  function externalRows() {
2735
3165
  var rows = [];
2736
3166
  var targets = Array.isArray(cfg.externalTargets) ? cfg.externalTargets : [];
2737
3167
  for (var i = 0; i < targets.length; i++) {
2738
3168
  (function (i) {
2739
3169
  var target = targets[i] || {};
3170
+ var type = externalType(target.type);
3171
+ var enabled = target.enabled !== false;
3172
+ var mainFields = type === "wxpusher"
3173
+ ? targetField("token", i, target.token, t("externalTokenPlaceholder"), t("externalTokenField"))
3174
+ : targetField("url", i, target.url, t("externalUrlPlaceholder"), t("externalUrlField"));
2740
3175
  rows.push(h("div", { className: "dnm-targetRow" }, [
2741
3176
  h("select", {
2742
3177
  className: "dnm-select",
2743
- value: target.type || "custom",
3178
+ value: type,
2744
3179
  "aria-label": t("externalTypeField"),
2745
3180
  onChange: function (e) { updateTarget(i, { type: e.currentTarget.value }); }
2746
- }, [
2747
- h("option", { value: "discord" }, t("externalTypeDiscord")),
2748
- h("option", { value: "bark" }, t("externalTypeBark")),
2749
- h("option", { value: "gotify" }, t("externalTypeGotify")),
2750
- h("option", { value: "custom" }, t("externalTypeCustom"))
2751
- ]),
2752
- h("input", {
2753
- className: "dnm-input",
2754
- value: target.url || "",
2755
- placeholder: t("externalUrlPlaceholder"),
2756
- "aria-label": t("externalUrlField"),
2757
- onChange: function (e) { updateTarget(i, { url: e.currentTarget.value }); }
2758
- }),
3181
+ }, EXTERNAL_TYPE_OPTIONS.map(function (o) {
3182
+ return h("option", { value: o[0] }, t(o[1]));
3183
+ })),
3184
+ mainFields,
3185
+ h("span", { className: "dnm-targetStatus" + (enabled ? "" : " off") }, externalStatusText(target)),
3186
+ h("button", {
3187
+ type: "button",
3188
+ role: "switch",
3189
+ "aria-checked": enabled ? "true" : "false",
3190
+ className: "dnm-switch" + (enabled ? " on" : ""),
3191
+ "aria-label": t("externalTargetToggle"),
3192
+ onClick: function () { updateTarget(i, { enabled: !enabled }); }
3193
+ }, h("span", { className: "dnm-knob" })),
2759
3194
  h("button", {
2760
3195
  type: "button",
2761
3196
  className: "dnm-btn",
@@ -2763,10 +3198,49 @@
2763
3198
  onClick: function () { removeTarget(i); }
2764
3199
  }, t("externalRemove"))
2765
3200
  ]));
3201
+ // Fields that only some target types have. They live on their own
3202
+ // line so the type select and the URL stay readable.
3203
+ var sub = [];
3204
+ if (type === "wxpusher") {
3205
+ sub.push(targetField("uid", i, target.uid, t("externalUidPlaceholder"), t("externalUidField")));
3206
+ }
3207
+ if (type === "feishu" || type === "custom") {
3208
+ var secretHint = type === "feishu" ? t("externalSecretPlaceholderFeishu")
3209
+ : t("externalSecretPlaceholderCustom");
3210
+ sub.push(targetField("secret", i, target.secret, secretHint, t("externalSecretField")));
3211
+ }
3212
+ if (type !== "discord" && type !== "bark" && type !== "gotify") {
3213
+ sub.push(targetField("template", i, target.template, t("externalTemplatePlaceholder"), t("externalTemplateField")));
3214
+ }
3215
+ if (sub.length) rows.push(h("div", { className: "dnm-targetRow dnm-targetRowSub" }, sub));
2766
3216
  })(i);
2767
3217
  }
2768
3218
  return rows;
2769
3219
  }
3220
+ // The persisted "these never made it" log: the alert was already shown
3221
+ // locally, so this list is the only place a lost external delivery
3222
+ // surfaces. Only a title/body snippet is kept, never the endpoint.
3223
+ function deadLetterRow() {
3224
+ var dead = readExternalDead();
3225
+ var last = dead[0];
3226
+ var text = dead.length
3227
+ ? t("externalDeadHint") + dead.length + t("externalDeadUnit") + (last && last.at ? " · " + t("externalDeadLast") + (last.host ? last.host + " · " : "") + last.at : "")
3228
+ : t("externalDeadNone");
3229
+ return h("div", { className: "dnm-row" }, [
3230
+ h("div", { className: "dnm-rowText" }, [
3231
+ h("div", { className: "dnm-rowLabel" }, t("externalDeadLabel")),
3232
+ h("div", { className: "dnm-hint" }, text)
3233
+ ]),
3234
+ dead.length ? h("div", { className: "dnm-btnRow" }, [
3235
+ h("button", {
3236
+ type: "button",
3237
+ className: "dnm-btn",
3238
+ "aria-label": t("externalDeadClear"),
3239
+ onClick: function () { clearExternalDead(); setFeedback(t("externalDeadCleared")); patch({}); }
3240
+ }, t("externalDeadClear"))
3241
+ ]) : null
3242
+ ]);
3243
+ }
2770
3244
  function updateTarget(i, patchFields) {
2771
3245
  var list = (Array.isArray(cfg.externalTargets) ? cfg.externalTargets : []).slice();
2772
3246
  var cur = list[i] || {};
@@ -3029,6 +3503,7 @@
3029
3503
  }, t("externalAdd"))
3030
3504
  ])
3031
3505
  ])],
3506
+ [deadLetterRow()],
3032
3507
  [tipRow("dnm-row", t("externalTestHint"), [
3033
3508
  h("div", { className: "dnm-rowText" }, [
3034
3509
  h("div", { className: "dnm-rowLabel" }, t("externalTestLabel")),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "离开 DSH 页面也能收到提醒:需要你操作 / 后台回复完成时发系统通知、标题标记与提示音,支持审批快捷裁决与外部推送,Web 版与桌面版均可用。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,9 @@
43
43
  "discord",
44
44
  "bark",
45
45
  "gotify",
46
+ "feishu",
47
+ "serverchan",
48
+ "wxpusher",
46
49
  "push-notification",
47
50
  "桌面通知",
48
51
  "消息提醒",