Archaofan/dsh-notify-relay ↗★ 0

dsh-notify-relay

DSH 通知中继:把任务完成/失败/待审批等生命周期事件经过去重、免打扰、摘要合批的规则中枢,外联到 Bark/Server酱/Telegram/企业微信/飞书/钉钉/ntfy/通用 webhook。失败自动重试且重启不丢。严重度分级(Bark level/call、ntfy Priority、钉钉 isAtAll)、单通道熔断、抖动退避、外联自检告警。零运行时依赖。 适合需要将任务状态、审批请求等实时推送到手机或群聊的运维及重度用户。

패키지
dsh-notify-relay
호환성
미검증
Harness peer 범위
>=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.2.0-0
버전
0.4.3
라이선스
MIT
최근 업데이트
2026. 9. 25.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Archaofan/dsh-notify-relay

dsh-notify-relay

The outbound rule center for DSH: lifecycle events — task done, task failed, task aborted, task blocked, request failed, approval asked, approval decided, tool failed — go through dedup, quiet hours and digest batching, then out to Bark, ServerChan, Telegram, WeCom, Feishu, DingTalk, ntfy or any webhook. Failed deliveries retry with backoff and survive a restart.

Zero runtime dependencies, no build step, two source files (host face + browser face). Bilingual (zh / en) — the UI follows DSH's own language, and the language of the text pushed to your phone is set separately.

中文说明

Why another notifier

There are already plenty of DSH plugins that forward a message somewhere. What none of them do is decide whether the message should be sent at all. That decision is the whole product:

RuleWhat it stops
DedupThe same failure re-firing every second. A fingerprint of (kind, session, title, body) suppresses a repeat inside a 1–1440 minute window
Quiet hoursA build breaking at 02:40. Wrap-aware (22:00 → 08:00 crosses midnight); each repeat is either dropped or held for the next digest
DigestTwenty failures arriving as twenty pushes. Held notifications are batched into one message every N minutes, flushed on demand
Mute/notify mute 60 while you are in the middle of something. Suppresses everything, including fresh fingerprints
Approval pierceQuiet hours and digest never hold an approval request. Holding one until 08:00 stalls the task until 08:00, and the user blames the notifier, not the clock
RetryA channel that is briefly down. A failed delivery goes into a durable outbox and retries with 30s → 1m → 2m → … → 30min backoff; the queue survives a restart
RoutingPer-channel event filters: Telegram only for failures, Bark for everything
RedactionA token in a log file. Secrets are stored under a •••••••• mask in every API response and never appear in the delivery log

The channel adapters are deliberately thin — seven of them, each a pure function that builds one payload. The value is the layer above them.

What you see

WhereWhat you get
Official settings pageSettings navigation → Outbound relay: master switch, eight event switches, dedup window, quiet hours (start / end / mode), digest interval, delivery language, pending-retry queue, and the channel editor
Channel editorAdd / edit / delete channels; per-channel name, kind, endpoint, secrets, event filter, and a Test button that fires one real delivery to that channel
Sidebar footerA status pill — on/off, last delivery outcome, rail mode when the sidebar is collapsed. Clicking it opens the recent delivery log
Slash commands/notify status, /notify test [channel], /notify mute , /notify unmute, /notify flush, /notify retry — no model round-trip

Rows where the rule center decided not to deliver also appear in the delivery log, with the reason (dedup / muted / quiet-hold / digest / no channel). "Did it fire at all?" is the most common complaint about notification plugins in this ecosystem, and a log of successful sends cannot answer it — the interesting row is the missing one.

The channel editor shows a masked secret and only sends it back to the host if you actually change it, so opening the editor and pressing Save never wipes a token you did not touch.

Channels

KindEndpointNotes
barkyour Bark server URLiOS push, group = dsh-notify-relay
serverchanyour SendKeyServerChan (Server酱)
telegrambot token + chat idsendMessage, silent on task-done
wecomwebhook keyWeCom robot markdown
feishuwebhook tokenFeishu interactive card
dingtalkaccess token + sign secret (optional)DingTalk custom robot markdown; critical @-mentions everyone
ntfytopic (+ optional server)ntfy JSON publish
webhookany URLGeneric JSON POST; an optional custom header carries the secret

Each channel has its own event filter (* for everything). Unknown kinds are dropped at validation time rather than failing the boot, so a config written by a newer version cannot take the plugin down. Unknown secret fields inside a known kind are kept — dropping them would silently destroy credentials.

Install

Verified against DSH 0.1.6-alpha.2 and 0.1.7-rc.2. The plugin code is identical across both: nothing is version-gated at runtime, and the only 0.1.7 difference found was the first-run onboarding (a full-page mask that intercepts every click), which affects the test harness rather than the plugin.

# production profile
dsh plugin --profile web add github:Archaofan/dsh-notify-relay --ignore-scripts

# or from a local checkout / tarball
dsh plugin --profile web add file:./dsh-notify-relay-0.3.3.tgz --ignore-scripts

--ignore-scripts matters: the plugin has no install scripts and no runtime dependencies, so refusing to run them is both faster and safer.

After installing, restart DSH (or reload the web app) and open Settings → Outbound relay.

If the second install fails with ERR_PNPM_MISSING_TARBALL_INTEGRITY

This is a pnpm bug, not a plugin one, and it reproduces in plain pnpm with no DSH involved:

$ pnpm add https://github.com/Archaofan/dsh-notify-relay/releases/download/v0.3.3/dsh-notify-relay-0.3.3.tgz
# ok, but the lockfile records `resolution: {tarball: ...}` with NO integrity
$ pnpm add 
ERR_PNPM_MISSING_TARBALL_INTEGRITY  Cannot install package "dsh-notify-relay@...":
its lockfile entry has no "integrity" field, so pnpm cannot verify the tarball.

When pnpm serves a tarball from its content-addressable store rather than downloading it, it writes the lockfile entry without an integrity field. The install that caused it succeeds; the next install in that profile then refuses to run, because pnpm will not install a tarball it cannot verify.

The first install always works. So this only bites when you add a second plugin to a profile that already carries one.

To clear it, delete both files and reinstall — deleting only the lockfile does not work, because pnpm regenerates it from package.json and the store is still warm:

# /profiles/
/
rm -rf node_modules pnpm-lock.yaml
dsh plugin --profile web add file:./dsh-notify-relay-0.3.3.tgz --ignore-scripts

pnpm store prune, pnpm add --force, and pointing at a cold --store-dir were all tested and none of them clear it once node_modules still holds the package.

Configure

The fastest path is the settings page. If you prefer to seed the file directly, it lives at /storages/notify-relay/config.json and is validated on every load — a bad value falls back to the default rather than crashing:

{
  "enabled": true,
  "language": "en",
  "events": {
    "task.done": false,
    "task.failed": true,
    "task.aborted": true,
    "task.blocked": true,
    "request.failed": true,
    "approval.asked": true,
    "approval.decided": false,
    "tool.failed": true
  },
  "dedup": { "windowMinutes": 10 },
  "quiet": { "enabled": false, "start": "22:00", "end": "08:00", "mode": "digest" },
  "digest": { "enabled": false, "intervalMinutes": 30 },
  "deepLink": "",
  "channels": [
    {
      "id": "phone",
      "name": "My phone",
      "kind": "bark",
      "url": "https://api.day.app/YOUR_KEY",
      "secrets": {},
      "events": ["*"]
    }
  ]
}

mode is digest (hold the repeats, flush as one message) or drop.

language is the delivery language (zh / en). It governs the text that leaves the machine — the digest title, the /notify replies, the suppression reasons. The UI language still follows DSH's own setting, because the person reading the settings page and the person reading a push at 2am are not necessarily the same person. An unrecognised value falls back to zh.

deepLink is optional. Only Bark (url) and ntfy (Click) have a native tap-target field; the other channels have no equivalent and get nothing rather than a URL pasted into the body where it is not clickable. It must be an http(s) URL containing {session}, which is substituted per notification — a link without a session is a lie, so it is not sent:

"deepLink": "https://dsh.example.com/session/{session}"

Severity

A flat "it happened" is not enough. An approval request and a task-done are both events, but one costs you money if it is missed and the other is trivia. Every mature notification system separates severity from content; a notifier that cannot forces you to either miss the important one or be spammed by the unimportant one.

KindSeverity
approval.askedcritical
task.failed, task.aborted, task.blocked, request.failed, tool.failed, relay.degradedhigh
task.done, approval.decided, testnormal
(nothing maps to low; it exists so a channel can downgrade)low

A digest is not in this table, because it has no severity of its own. It inherits the most severe kind it holds: a digest of five task.failed events is five high events and arrives as high, not normal. Turning on batching is a request for fewer messages, not for less urgency — and until 0.4.1 the digest was hardcoded to normal, so a batch of failures reached Bark as active instead of timeSensitive and ntfy as Priority 3 instead of 4, with nothing in the log saying so.

The ceiling is high, never critical: approval.asked is the only critical kind and it pierces batching, so it is never held. A digest of failures should break through, but must not @-mention a whole DingTalk group.

Severity is not decoration — it lands on real API fields, verified against the vendor docs:

ChannelFieldCriticalNormal
Barklevelcritical + call: '1' + volume: '10'active
Bark—low → passive, high → timeSensitive
ntfyPriority53 (low → 2, high → 4)
Telegramdisable_notificationfalsetrue only for low
webhookJSON severity"critical""normal"

Bark's critical + call is the only primitive in this plugin's whole channel set that breaks through iOS silent mode and Do Not Disturb. It is reserved for approval requests, because using it for anything else is how a notifier gets uninstalled. Server酱, WeCom and Feishu have no severity primitive at all, so they keep their existing shape — inventing a field the API ignores is worse than omitting one.

Circuit breakers

Without one, a channel that is hard down — revoked token, decommissioned host, DNS that stopped resolving — is retried on every single notification, six times each, forever. Each retry costs a full timeout, so the outbox grows, the backoff ceiling stretches to thirty minutes, and your working channels are delayed behind the dead one.

Three consecutive failures open a channel's breaker. It then gets no requests at all until the cooldown elapses (1m → 5m → 15m → 30m, doubling from one minute), when exactly one probe goes out. A success resets everything, including the ladder, so a channel that recovers does not carry a longer penalty than it earned.

Two deliberate choices:

  • A tripped channel is not queued for retry. Retrying it per-notification is the behaviour the breaker exists to stop. The skip is still written to the log with error: "circuit open" — a silent skip is the exact failure this plugin was written to eliminate.
  • Breakers are not persisted. A breaker is a statement about the network now, and a restart is exactly when the network may have been fixed. Re-tripping after a restart costs three attempts; trusting a stale "unhealthy" flag across a restart could suppress a channel for thirty minutes for no reason.

The channel card in the settings page shows the breaker state and how long until the probe, so a tripped channel explains itself instead of looking like a config bug.

The relay reports on itself

This is the single highest-leverage thing a notifier can do, and the one this plugin was missing. A notifier that quietly stops notifying is strictly worse than no notifier, because you believe you are covered. Healthchecks.io is built on exactly this idea — a Period plus a Grace Time, so that silence is itself an alert.

Every tick of the heartbeat timer (the same one that drains the outbox) checks three things and, at most once an hour, warns you through a healthy channel:

  • a channel whose breaker is open, with its id
  • notifications stuck in the retry queue for more than 30 minutes, with their age
  • nothing delivered at all since start-up, with an enabled channel configured

It is deliberately conservative. Once an hour, because a warning that fires on every tick trains you to ignore it. Only through a healthy channel, because warning through the dead one adds a failure to the thing being reported and could recurse. Never queued for retry, because a degraded warning that fails must not join the outbox it is describing. And never through classify(), because relay.degraded is not a user-configurable event and the event switches must not be able to silence it.

If every channel is open there is nobody to tell, so the warning is recorded as a log row instead — at least it is visible to the one person who opens the panel.

/notify status reports the same state on demand: breaker ids, the age of the oldest queued item, and how long since the last successful delivery.

Where the events come from

Eight kinds, two arrival paths, and the difference is not cosmetic:

KindSourceDefault
task.doneturn/end, reason.kind = completedoff
task.failedagent/erroron
task.abortedturn/end, reason.kind = abortedon
task.blockedturn/end, reason.kind = blockedon
request.failedagent/request-erroron
approval.askedapproval/askedon, pierces quiet hours and digest
approval.decidedapproval/decidedoff
tool.failedtool/result with an object data.erroron

turn/end carries a reason (completed / aborted / blocked / error / max-tokens / interrupted), so "the task finished" and "the task was aborted" are two different notifications rather than one bland "task done".

Session events (turn/*, approval/*, tool/result) are dispatched once, under the single name session/event, as (session, event) with the real name in event.type. Registering ctx.on('turn/end') buys a listener on an event DSH never dispatches — which is exactly the dead half of the first release's event coverage, and the reason the harness carries a "listener on a session-event sub-name" broken variant.

How the rule engine decides

Every event goes through classify(), which returns exactly one verdict:

off          → the master switch is off; nothing happens
muted        → inside a mute window; nothing happens
dedup        → the same fingerprint was already delivered inside the window
quiet-drop   → inside quiet hours with mode = drop
quiet-hold   → inside quiet hours with mode = digest; queued for the next flush
digest       → digest batching is on; queued for the next flush
send         → delivered now

Delivery itself never throws and never blocks the event: a channel that times out (10s) or refuses the connection is recorded as failed in the log and the other channels still get their message. Concurrency is capped at 3 so a misconfigured endpoint cannot flood the socket.

Changing the digest interval takes effect immediately

intervalMinutes is read when the timer is armed, and the timer is re-armed on the s