跳到正文
dsh-market 浏览插件 GitHub EN

Archaofan/dsh-notify-relay

把 DSH 的八类生命周期事件过去重、免打扰、摘要合批三道关后外发到七个通道。严重度映射到 Bark、ntfy、Telegram 和 webhook 真正支持的字段;失败投递带抖动退避重试并可跨重启恢复;单通道熔断避免反复敲一个已死的地址;自检发现中继降级时主动报告而不是静默。

Star 数 ★ 0 分类 通知与集成 收录于 2026-09-26

安装

在 DeepSeek Harness 里通过 dsh-market 安装

dsh plugin --profile web add dshmarket

或使用命令行

dsh plugin --profile web add "https://github.com/Archaofan/dsh-notify-relay/releases/download/v0.3.2/dsh-notify-relay-0.3.2.tgz"

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络。请先审阅源码,并尽量锁定 commit(github:owner/repo#sha)。

截图

README

DSH 的外联规则中枢:任务完成、任务失败、任务中止、任务受阻、请求失败、待审批、 审批已决、工具失败这几类生命周期事件,先过去重、免打扰、摘要合批三道关,再发到 Bark、Server酱、Telegram、企业微信、飞书、钉钉、ntfy 或任意 webhook。投递失败自动重试, 重启不丢。

零运行时依赖、无构建步骤,两个源文件(host 面 + 浏览器面)。中英双语,界面跟随 DSH 自己的语言设置;推送到你手机上的文案语言单独可设。

English

为什么还要有一个通知插件

市面上已经有不少"把消息转发到某处"的 DSH 插件。缺的是这条消息该不该发的 判断层——而这恰恰是唯一有价值的部分:

规则 它拦住了什么
去重 同一个错误每秒重报一次。按 (类型, 会话, 标题, 正文) 取指纹,在 1–1440 分钟窗口内抑制重复
免打扰 凌晨两点半构建挂了。支持跨零点(22:00 → 08:00);窗口内的重复要么丢弃,要么留给下次摘要
摘要合批 二十个失败推二十条。被挂起的通知攒成一条,每 N 分钟发一次,也可手动立即发送
静音 手头正忙时 /notify mute 60。压掉一切,连新指纹也压
待审批穿透 免打扰和摘要不拦待审批。压住它到早上八点,等于让任务停摆到早上八点,而用户只会怪插件
失败重试 通道临时挂了。失败的投递进持久化 outbox,按 30s→1m→2m→…→30min 指数退避重试,重启后接着试
分流 每个通道独立的事件过滤:失败才发 Telegram,全部发 Bark
脱敏 token 落在日志文件里。密钥在所有 API 响应里都以 •••••••• 占位,投递日志中绝不出现

通道适配器刻意做得很薄——七个,每个都是一个纯函数,负责拼一条 payload。价值在 它们上面那一层。

你会看到什么

位置 内容
官方设置页 设置导航 → 外联中枢:总开关、八个事件开关、去重窗口、免打扰(起 / 止 / 模式)、摘要间隔、外发语言、待重试队列、通道编辑器
通道编辑器 增删改通道;每个通道可设名称、类型、地址、密钥、事件过滤,并有测试按钮,立刻真实投递一次
侧边栏底部 状态胶囊——开关态、最近一次投递结果,侧边栏收起时自动切紧凑形态;点开是最近的投递记录
斜杠命令 /notify status、/notify test [通道]、/notify mute <分钟>、/notify unmute、/notify flush、/notify retry,不经过模型

投递日志里未投递的行也会出现,并写明原因(去重 / 静音 / 免打扰挂起 / 摘要挂起 / 无可用通道)。"到底发没发"是这个生态里被抱怨得最多的问题,而一份只记录成功发送的日志 恰恰答不了它——因为有意思的那一行正是缺失的那一行。

通道编辑器里的密钥显示为掩码,只有你真的改动了它才会回传给 host——所以打开编辑器 直接按保存,不会把没动过的 token 清空。

通道

类型 地址 说明
bark 你的 Bark 服务地址 iOS 推送,group = dsh-notify-relay
serverchan 你的 SendKey Server酱
telegram bot token + chat id sendMessage,任务完成时静音
wecom webhook key 企业微信机器人 markdown
feishu webhook token 飞书交互卡片
dingtalk access token + 加签密钥(可空) 钉钉自定义机器人 markdown,critical 时 @所有人
ntfy topic(可选服务地址) ntfy JSON 发布
webhook 任意 URL 通用 JSON POST;可配一个自定义请求头携带密钥

每个通道有独立的事件过滤(* 表示全部)。未知类型在校验阶段直接丢弃而不是让启动 失败——新版写进来的配置不会把旧版插件拖垮。而已知类型下未知的密钥字段会保留—— 丢弃它们等于静默毁掉凭证。

安装

已在 DSH 0.1.6-alpha.2 与 0.1.7-rc.2 两条线上实测通过。两条线上的插件代码完全 相同:运行时不做任何版本门禁,0.1.7 唯一发现的差异是首次运行引导(一个会拦掉所有 点击的整页遮罩),那影响的是测试脚本而不是插件本身。

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

# 或本地目录 / tarball
dsh plugin --profile web add file:./dsh-notify-relay-0.3.3.tgz --ignore-scripts

--ignore-scripts 是必要的:本插件没有安装脚本,也没有运行时依赖,拒绝执行它们 既更快也更安全。

装完重启 DSH(或重新加载 web 应用),进入 设置 → 外联中枢。

如果第二次安装报 ERR_PNPM_MISSING_TARBALL_INTEGRITY

这是 pnpm 的 bug,不是插件的问题,在完全不涉及 DSH 的纯 pnpm 里就能复现:

$ pnpm add https://github.com/Archaofan/dsh-notify-relay/releases/download/v0.3.3/dsh-notify-relay-0.3.3.tgz
# 成功,但 lockfile 里写的是 `resolution: {tarball: ...}`,没有 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.

pnpm 从内容寻址的 store 里直接取 tarball(而不是重新下载)时,写进 lockfile 的 条目会缺 integrity 字段。触发它的那次安装是成功的;同一 profile 里的下一次 安装才会被拒绝,因为 pnpm 不肯安装一个无法校验的 tarball。

第一次安装总是成功的。 所以这只在"往已经装了一个插件的 profile 里再装一个" 时才咬人。

清掉它要同时删两个文件——只删 lockfile 没用,pnpm 会从 package.json 重新 生成,而 store 还是热的:

# <DSH_HOME>/profiles/<profile>/
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、以及换一个冷的 --store-dir 都测过, 只要 node_modules 里还留着那个包,没有一个能清掉它。

配置

最快的路径是设置页。想直接写文件的话,配置在 <DSH_HOME>/storages/notify-relay/config.json,每次加载都会校验——非法值回落到 默认值而不是让插件崩掉:

{
  "enabled": true,
  "language": "zh",
  "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": "我的手机",
      "kind": "bark",
      "url": "https://api.day.app/YOUR_KEY",
      "secrets": {},
      "events": ["*"]
    }
  ]
}

mode 为 digest(挂起重复,合成一条发送)或 drop(直接丢弃)。

language 是外发语言(zh / en),决定推送到你手机上的文案、/notify 的回复 和摘要标题。界面语言仍跟随 DSH 自己的设置——两者是独立的,因为读界面的你和在凌晨两点 读到推送的可能不是同一个人。取值不合法时回落到 zh。

deepLink 是可选项。只有 Bark(url)和 ntfy(Click)有点击跳转字段,其他通道没有 对应能力,拿到的就是空——而不是把一串不可点的 URL 塞进正文。它必须是含 {session} 的 http(s) 地址,{session} 按通知逐条替换;没有会话可替换的链接等于说谎,所以不会 发送:

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

严重度

一句"发生了"远远不够。待审批和任务完成都是事件,但前者漏掉要花钱,后者只是噪音。成熟 的通知系统都把严重度和内容分开处理;做不到这一点的通知器,逼你在"漏掉重要的"和"被不重要 的刷屏"之间二选一。

事件 严重度
approval.asked 紧急(critical)
task.failed、task.aborted、task.blocked、request.failed、tool.failed、relay.degraded 重要(high)
task.done、approval.decided、测试 普通(normal)
(没有事件映射到 low;它存在是为了让通道能降级) 低(low)

摘要不在这张表里,因为它自己没有严重度。 它继承它所含事件里最高的那一级:五个 task.failed 攒成的一条摘要,就是五个 high,也按 high 发出去。开摘要是为了少收几条 消息,不是为了少几分紧迫——而在 0.4.1 之前,摘要的严重度被硬编码成 normal,于是一条 失败摘要到 Bark 是 active 而不是 timeSensitive,到 ntfy 是 Priority 3 而不是 4, 日志里一个字都不提。

上限是 high,永远不是 critical:approval.asked 是唯一的 critical,而它穿透摘要,从 来不会被挂起。所以一条失败摘要该穿透静音,但不该 @ 整个钉钉群。

严重度不是装饰,它落在各通道真实的 API 字段上,且已对着官方文档逐一核对:

通道 字段 紧急 普通
Bark level critical + call: '1' + volume: '10' active
Bark — low → passive,high → timeSensitive
ntfy Priority 5 3(low → 2,high → 4)
Telegram disable_notification false 仅 low 为 true
webhook JSON severity "critical" "normal"

Bark 的 critical + call 是这套通道里唯一能穿透 iOS 静音和勿扰的原语,因此只留给 待审批:拿它发"任务完成",是这个插件被卸载的最快路径。Server酱、企业微信、飞书根本没有 严重度字段,就保持原样——编一个 API 不认识的字段,比不给字段更糟。

单通道熔断

没有熔断,一个彻底挂掉的通道(token 被吊销、主机下线、DNS 不再解析)会在每一条通知 上被重试六次,永远如此。每次重试都要等满一个超时,于是发件箱膨胀、退避上限被拉到半小时, 而你好的通道被这个死通道堵在后面。

同一通道连续失败三次即打开熔断,之后完全不发请求,直到冷却结束(1 分 → 5 分 → 15 分 → 30 分,从 1 分钟起翻倍),届时只放一个探测请求出去。成功则全部重置,冷却阶梯也一起归零, 所以恢复的通道不会背上比它应得更长的惩罚。

两个刻意的选择:

  • 熔断中的通道不进重试队列。 逐条重试它正是熔断要阻止的行为。跳过仍然会写进日志, error: "circuit open"——静默跳过正是这个插件要消灭的失败模式。
  • 熔断状态不落盘。 熔断描述的是"网络现在坏了",而重启恰恰是最可能已经修好的时刻。 重启后重新触发只需三次尝试,很便宜;反过来,跨重启信任一个过期的"不健康"标记,可能让 一个正常通道被无故静音半小时。

设置页的通道卡片会显示熔断状态和距下次探测的时长,让被熔断的通道自己解释,而不是看起来 像一个配置错误。

中继会报告自己的状态

这是一个通知器能做的最有价值的事,也是这个插件此前唯一缺失的能力。悄悄不再通知的通知器, 比没有通知器更糟,因为你以为自己被覆盖着。Healthchecks.io 就是建立在这个想法上——一个 周期加一个宽限期,让"沉默"本身成为告警。

心跳计时器(和发件箱排空共用同一个)每次跳动都会检查三件事,并且最多每小时一次,只通过 健康的通道告警:

  • 有通道熔断中,附上它的 id
  • 有通知在重试队列里卡了超过 30 分钟,附上等了多久
  • 自启动以来什么都没投递出去,而明明有启用的通道

它的保守是刻意的:每小时一次,因为每次跳动都告警只会训练你忽略它;只走健康通道,因为通过 死通道告警等于给被报告的对象再添一次失败,甚至可能递归;绝不入重试队列,因为一条失败的降级 告警不该加入它正在描述的发件箱;也绝不经过 classify(),因为 relay.degraded 不是用户 可配置的事件,事件开关不该能把它静音。

如果所有通道都熔断了,那就没人可告诉。此时降级告警会写成一条日志——这至少是诚实的:打开面板 的那个人还能看见它。

/notify status 也随时报告同样的状态:熔断的通道 id、最早就绪项的等待时长,以及距上次成功 投递多久。

事件从哪来

八类事件,两种到达方式,区别不是装饰:

事件 来源 默认
task.done turn/end,reason.kind = completed 关
task.failed agent/error 开
task.aborted turn/end,reason.kind = aborted 开
task.blocked turn/end,reason.kind = blocked 开
request.failed agent/request-error 开
approval.asked approval/asked 开,穿透免打扰与摘要
approval.decided approval/decided 关
tool.failed tool/result,且 data.error 是对象 开

turn/end 会带一个 reason(completed / aborted / blocked / error / max-tokens / interrupted),所以"任务完成"和"任务被中止"是两条不同的通知,而不是 把所有结束都报成完成。

会话类事件(turn/*、approval/*、tool/result)在整个 DSH 里只以 session/event 一个名字分发一次,载荷是 (session, event),真实名字在 event.type 里。监听 ctx.on('turn/end') 等于监听一个 DSH 从不分发的事件——这正是第一版静默死掉的半边事件 覆盖,也是 harness 里那个"监听器注册在会话子事件名上"坏变体的由来。

规则引擎怎么判

每个事件都过一遍 classify(),只会得到其中一个结论:

off          总开关关着,什么都不做
muted        在静音窗口内,什么都不做
dedup        同一指纹在窗口内已经投过
quiet-drop   在免打扰时段内,且模式为 drop
quiet-hold   在免打扰时段内,且模式为 digest;挂起等下次摘要
digest       摘要合批开着;挂起等下次摘要
send         立即投递

投递本身不抛异常、也不阻塞事件:某个通道超时(10 秒)或连不上,只在日志里记一条 failed,其他通道照常收到。并发上限 3,一个配错的地址不会把连接池打满。

改摘要间隔,当场生效

intervalMinutes 是在定时器上弦时读的,而改它那次保存会重新上弦——所以把 30 改成 5,设置页说生效就生效。0.4.3 之前不是这样:armDigest 在定时器已经跑着的时候直接返回, 所以对一个已经挂着事件的 relay,那次保存是空转的,待发的摘要仍按第一条事件到达时的 间隔发。值其实存对了,这才最难发现。

共用同一个 tick 的 outbox 心跳也一样,而且在那边更糟:它用首次上弦时捕获的那个 delay 重新上弦,于是心跳永远停在旧间隔上,每次刷新都是。

如果要改这块,有一个坑值得知道:disarmDigest() 会把挂起队列一起清空。合批被关掉时 这是对的,仅仅改间隔时则是灾难——每一条攒着的通知都会在"本该重新排定它们投递时间"的那次 保存上被丢掉。改期走的是 rearmDigest(),只丢定时器不动队列;另有一个坏构建变体把 naive 写法换回来,证明门禁还抓得住。

退避是带有界抖动的指数退避:30 秒、1 分、2 分、4 分……上限 30 分钟,再乘以 [0.75, 1.25] 区间内的一个均匀抽样。没有抖动的话,所有待重试项会算出同一个下次尝试 时刻——于是共享通道一恢复,或者进程重启、整个发件箱一次性重载的那一刻,全部排队的通知会同 时砸过去。一个自身的失败模式是"很多东西同时失败"的通知器,正是产生惊群的那个场景。取 ±25% 而不是完整的 [0, cap] 抖动,是因为完整抖动可能把重试排到几乎立刻执行,对一个 10 秒 超时的通道那就是热循环。

摘要继承它所含事件种类的受众。这个区别在 0.2.0 里是个 bug:摘要是以 kind: 'digest' 投递的,于是 channelsFor 拿它去匹配每个通道的 events 列表,把它过滤掉了——一个配了 events: ['task.failed'] 的通道(也就是本 README 自己的示例)根本收不到任何摘要。

验证

这个插件不是靠"看起来对"就发布的。四道门禁,全部必须通过,其中三道是专门设计成 会失败的:

门禁 证明了什么
.sandbox/host-harness.cjs fail-loud 的 inject 契约、规则引擎(去重 / 免打扰 / 严重度映射 / payload 构造 / 脱敏)、一次打到回环服务器的真实 HTTP 投递、深链在真实请求里落地、熔断状态机、持久化 outbox 的入队 / 退避 / 重试 / 放弃 / 重启恢复、摘要间隔改期,18 个坏构建变体
.sandbox/client-harness.cjs 在严格假 ctx 下物化、inject 契约、设置页导航标签随语言切换、字典键对齐、编辑器完整往返、事件词表与 host 逐项对齐、通道词表与 host 逐项对齐,5 个坏变体
.sandbox/live-check.cjs 插件在真实 DSH 里启动:写配置 → 读回 → 真实 socket 投递 → 日志 → 复位 → /retry → 外发语言
.sandbox/e2e-notify.mjs 浏览器面在真实 GUI 里:状态胶囊、投递面板、官方设置分区、以及一次从界面穿透到 host 的修改
node .sandbox/gate.cjs        # 两个 harness,两种语言,外加全部变体

两个 harness 都按语言各跑一遍——只在中文下能物化的构建,证明不了英文界面任何事。

发布版也真跑了 e2e,不是推断出来的

v0.3.3 与 v0.2.5 的 index.js / client.js 和上一版逐字节相同,所以"再跑一遍 浏览器测的也是同样的字节"听起来是个正当理由。但那是推断,而门禁存在的意义正是 把推断换掉。.sandbox/verify-e2e-releases.cjs 把两个插件的浏览器面都打到 真实 GUI 上,覆盖两条运行时:

运行时 notify-relay v0.3.3 session-suspend v0.2.5
DSH 0.1.6-alpha.2 28 项检查,0 失败 19 项检查,0 失败
DSH 0.1.7-rc.2 28 项检查,0 失败 7 项检查,0 失败

两个插件都激活成功(没有 "did not activate")、不抛 page error、不抛 console error、状态胶囊 / 侧边栏行正常渲染、官方设置分区正常渲染,且界面上一次 修改能穿透回 host。session-suspend 在 0.1.7 上检查项更少,是因为那个 profile 是新建的、只有一个空草稿——hover 链路需要一个已停放的会话来比对,它选择跳过而 不是空跑通过。

只有"会失败的门禁"才抓到的 bug

一个从未执行过的 POST 路由。 第一版把 GET /config 和 POST /config 写成两条 路由。webServer.match() 按路径查表、完全忽略 method,于是第二次注册抛错,重复 注册被当成双重挂载吞掉,每次保存都落在读处理器上——返回 200,什么都没存。单元测不 到,因为 harness 里的假 webServer 是像真路由器那样按 method 匹配的。是靠一次真实启 动、读回一个没变的配置才发现的。修法是每路径一条路由、method 在 handler 内部分发; harness 的假 webServer 现在与真实实现逐字一致(按路径建 Map、重复即抛错),并加了 一个故意重复注册路径的坏变体。

被当成投递日志传进去的 logger。 registerRoutes(server, log) 的第二个参数名叫 log,传进去的却是 ctx.logger(PLUGIN_ID),把数组遮蔽掉了,于是 /log 每次都抛异常、 返回 500。直到 live-check.cjs 真的调了这个路由才发现。参数已经去掉,harness 现在 也会真的调用路由,而不只是断言它存在。

半个事件覆盖是死的。 第一版同时注册了 ctx.on('turn/end') 和 ctx.on('approval/asked')。这两个事件从不以这些名字分发——会话事件只在 session/event 下出现一次——所以两个监听器从未执行,"任务完成"和"待审批"两类通知 一条都没发过。改成 session/event + 内层 event.type 分支。harness 现在维护一份 DISPATCHED_EVENT_NAMES 注册表,断言每个监听器名字都在表内、HOST_EVENT_NAMES 与真实 注册一致、没有监听器落在会话子事件名上;两个坏变体分别复现"死监听器"和"包装器吞掉第二 个参数"两种形态。

mixiin 服务的方法在 ctx 上,不在服务上。 @cordisjs/plugin-timer 用 ctx.mixin('timer', [...]) 把方法直接挂到 context 上,所以 hostCtx.timeout(...) 是 对的。第一版 harness 只提供了 ctx.timer.timeout,fail-loud 代理理所当然地拒绝了 ctx.timeout——但 digest 路径从未带着排队项被走到过,于是这道门在一片从未执行的代码 上亮着绿灯。现在假 ctx 同时提供两个面,并且在 mixin 服务本身被声明时允许它的方法名。

事件词表两边各一份,然后漂移了。 浏览器面拿不到 host 的 EVENT_KINDS,只能自己 维护一份。turn-end 的 reason 被拆开之后 host 那边从四类长到八类,浏览器这份没动——设置页 出现四个开关管八个事件,四类事件静默不可配置,而两边各自都是自洽的,所以两道门全是绿的。 只有真实浏览器里数了一遍开关才发现。现在 client harness 直接 import host 的 EVENT_KINDS,逐项、按顺序比对两份列表,并切换语言两个方向确认每类事件在两个字典里都有 标签。

outbox 探针读错了 home。 变体运行器在自己的 finally 里恢复 DSH_HOME,而行为探针 跑在它之后——被改坏的插件每一次写入都落在真实用户目录,探针读的却是一个空的临时目录, 于是对一个 outbox 完全正常的构建报告"没有 outbox",给一个从未被真正观察到的回归放了绿 灯。现在探针自己把 DSH_HOME 指回去,并且在得出结论前先断言配置写入和 outbox 文件都确 实落地。

全部十处都记在 DEV-NOTES.md 里,附确切症状,下一个人不必重新踩。

卸载

dsh plugin --profile web remove dsh-notify-relay

许可

MIT

内容来自项目 README(GitHub)↗

评论

评论存放在 GitHub Discussions。用 GitHub 账号登录后可发表评论或点表情。