omni-notify-mcp 1.3.4 → 1.3.6

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/dist/ui/server.js CHANGED
@@ -28,7 +28,7 @@ function defaultConfig() {
28
28
  whatsapp: { enabled: false, instanceId: "", apiToken: "", phone: "" },
29
29
  sms: { enabled: false, accountSid: "", authToken: "", from: "", to: "" },
30
30
  email: { enabled: false, to: "" },
31
- ntfy: { enabled: false, topic: "", serverUrl: "https://ntfy.sh", token: "" },
31
+ ntfy: { enabled: false, topic: "", serverUrl: "" },
32
32
  discord: { enabled: false, webhookUrl: "", username: "Claude Notify" },
33
33
  slack: { enabled: false, webhookUrl: "" },
34
34
  teams: { enabled: false, webhookUrl: "" },
@@ -105,8 +105,6 @@ function maskSecrets(config) {
105
105
  c.telegram.token = MASKED;
106
106
  if (c.whatsapp?.apiToken)
107
107
  c.whatsapp.apiToken = MASKED;
108
- if (c.ntfy?.token)
109
- c.ntfy.token = MASKED;
110
108
  if (c.discord?.webhookUrl)
111
109
  c.discord.webhookUrl = MASKED;
112
110
  if (c.slack?.webhookUrl)
@@ -137,7 +135,6 @@ function mergePreservingSecrets(existing, update) {
137
135
  guard(["sms", "authToken"]);
138
136
  guard(["telegram", "token"]);
139
137
  guard(["whatsapp", "apiToken"]);
140
- guard(["ntfy", "token"]);
141
138
  guard(["discord", "webhookUrl"]);
142
139
  guard(["slack", "webhookUrl"]);
143
140
  guard(["teams", "webhookUrl"]);
@@ -146,6 +143,100 @@ function mergePreservingSecrets(existing, update) {
146
143
  const app = express();
147
144
  app.use(express.json());
148
145
  app.use(express.static(PUBLIC_DIR));
146
+ const ntfySubscribers = new Map();
147
+ function ntfyFanout(topic, message, title, priority, tags) {
148
+ const subs = ntfySubscribers.get(topic);
149
+ if (!subs || subs.size === 0)
150
+ return;
151
+ const id = Date.now();
152
+ const event = [
153
+ `id: ${id}`,
154
+ `event: message`,
155
+ `data: ${JSON.stringify({ id: String(id), time: Math.floor(id / 1000), event: "message", topic, title, message, priority, tags: tags ? tags.split(",") : [] })}`,
156
+ "",
157
+ "",
158
+ ].join("\n");
159
+ for (const sub of subs) {
160
+ try {
161
+ sub.res.write(event);
162
+ }
163
+ catch {
164
+ subs.delete(sub);
165
+ }
166
+ }
167
+ }
168
+ // SSE subscribe — ntfy app connects here
169
+ app.get("/ntfy/:topic/sse", (req, res) => {
170
+ const { topic } = req.params;
171
+ res.setHeader("Content-Type", "text/event-stream");
172
+ res.setHeader("Cache-Control", "no-cache");
173
+ res.setHeader("Connection", "keep-alive");
174
+ res.setHeader("X-Accel-Buffering", "no");
175
+ res.flushHeaders();
176
+ res.write(`: connected to omni-notify-mcp ntfy\n\n`);
177
+ const sub = { res, topic };
178
+ if (!ntfySubscribers.has(topic))
179
+ ntfySubscribers.set(topic, new Set());
180
+ ntfySubscribers.get(topic).add(sub);
181
+ const keepalive = setInterval(() => { try {
182
+ res.write(": keepalive\n\n");
183
+ }
184
+ catch {
185
+ clearInterval(keepalive);
186
+ } }, 30_000);
187
+ req.on("close", () => {
188
+ clearInterval(keepalive);
189
+ ntfySubscribers.get(topic)?.delete(sub);
190
+ });
191
+ });
192
+ // Also support ntfy's JSON stream endpoint
193
+ app.get("/ntfy/:topic/json", (req, res) => {
194
+ const { topic } = req.params;
195
+ res.setHeader("Content-Type", "application/x-ndjson");
196
+ res.setHeader("Cache-Control", "no-cache");
197
+ res.setHeader("Connection", "keep-alive");
198
+ res.flushHeaders();
199
+ const sub = { res, topic };
200
+ if (!ntfySubscribers.has(topic))
201
+ ntfySubscribers.set(topic, new Set());
202
+ ntfySubscribers.get(topic).add(sub);
203
+ const keepalive = setInterval(() => {
204
+ try {
205
+ res.write(JSON.stringify({ event: "keepalive", time: Math.floor(Date.now() / 1000) }) + "\n");
206
+ }
207
+ catch {
208
+ clearInterval(keepalive);
209
+ }
210
+ }, 30_000);
211
+ req.on("close", () => {
212
+ clearInterval(keepalive);
213
+ ntfySubscribers.get(topic)?.delete(sub);
214
+ });
215
+ });
216
+ // Publish endpoint — ntfy protocol POST
217
+ app.put("/ntfy/:topic", express.text({ type: "*/*" }), (req, res) => {
218
+ const { topic } = req.params;
219
+ const message = typeof req.body === "string" ? req.body : "";
220
+ const title = decodeURIComponent(req.headers["title"] || req.headers["x-title"] || "Claude Notify");
221
+ const priority = parseInt((req.headers["priority"] || req.headers["x-priority"] || "3")) || 3;
222
+ const tags = (req.headers["tags"] || req.headers["x-tags"] || "");
223
+ ntfyFanout(topic, message, title, priority, tags);
224
+ res.json({ id: String(Date.now()), time: Math.floor(Date.now() / 1000), event: "message", topic, title, message, priority });
225
+ });
226
+ app.post("/ntfy/:topic", express.text({ type: "*/*" }), (req, res) => {
227
+ const { topic } = req.params;
228
+ const message = typeof req.body === "string" ? req.body : "";
229
+ const title = decodeURIComponent(req.headers["title"] || req.headers["x-title"] || "Claude Notify");
230
+ const priority = parseInt((req.headers["priority"] || req.headers["x-priority"] || "3")) || 3;
231
+ const tags = (req.headers["tags"] || req.headers["x-tags"] || "");
232
+ ntfyFanout(topic, message, title, priority, tags);
233
+ res.json({ id: String(Date.now()), time: Math.floor(Date.now() / 1000), event: "message", topic, title, message, priority });
234
+ });
235
+ // Subscriber count endpoint (for badge)
236
+ app.get("/ntfy/:topic/subscribers", (req, res) => {
237
+ const count = ntfySubscribers.get(req.params.topic)?.size ?? 0;
238
+ res.json({ topic: req.params.topic, subscribers: count });
239
+ });
149
240
  // ── Config API ────────────────────────────────────────────────────────────────
150
241
  app.get("/api/config", (_req, res) => {
151
242
  res.json(maskSecrets(loadConfig()));
@@ -393,16 +484,13 @@ app.post("/api/test/ntfy", async (_req, res) => {
393
484
  return;
394
485
  }
395
486
  try {
396
- const base = (ntfy.serverUrl ?? "https://ntfy.sh").replace(/\/$/, "");
397
- const headers = {
398
- "Content-Type": "text/plain; charset=utf-8", "Title": encodeURIComponent("Claude Notify - test"), "Priority": "3", "Tags": "white_check_mark",
399
- };
400
- if (ntfy.token)
401
- headers["Authorization"] = `Bearer ${ntfy.token}`;
402
- const r = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, { method: "POST", headers, body: Buffer.from("Test from Claude Notify - ntfy is working!", "utf8") });
403
- if (!r.ok)
404
- throw new Error(`ntfy ${r.status}: ${await r.text()}`);
405
- res.json({ ok: true, message: `ntfy notification sent to topic '${ntfy.topic}'` });
487
+ const subs = ntfySubscribers.get(ntfy.topic)?.size ?? 0;
488
+ if (subs === 0) {
489
+ res.status(400).json({ error: `No subscribers on topic '${ntfy.topic}'. Open the ntfy app and subscribe to this topic first.` });
490
+ return;
491
+ }
492
+ ntfyFanout(ntfy.topic, "Test from Claude Notify - ntfy is working!", "Claude Notify - test", 3, "white_check_mark");
493
+ res.json({ ok: true, message: `ntfy notification sent to ${subs} subscriber(s) on topic '${ntfy.topic}'` });
406
494
  }
407
495
  catch (err) {
408
496
  res.status(500).json({ error: String(err) });
@@ -808,26 +896,17 @@ async function sendNotification(message, priority, client) {
808
896
  to: email.to, subject: "Claude Notify", text: message });
809
897
  });
810
898
  }
811
- // ntfy
899
+ // ntfy (built-in server — direct fanout, no external fetch)
812
900
  if (!desktopOnlyMode) {
813
901
  const ntfy = cfg.ntfy ?? {};
814
902
  if (ntfy.enabled && ntfy.topic) {
815
903
  await send("ntfy", async () => {
816
- const base = (ntfy.serverUrl ?? "https://ntfy.sh").replace(/\/$/, "");
817
904
  const priorityMap = { low: 2, normal: 3, high: 5 };
818
- const headers = {
819
- "Content-Type": "text/plain; charset=utf-8",
820
- "Title": encodeURIComponent("Claude Notify"),
821
- "Priority": String(priorityMap[priority] ?? 3),
822
- "Tags": priority === "high" ? "rotating_light" : "bell",
823
- };
824
- if (ntfy.token)
825
- headers["Authorization"] = `Bearer ${ntfy.token}`;
826
- const r = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, {
827
- method: "POST", headers, body: Buffer.from(message, "utf8"),
828
- });
829
- if (!r.ok)
830
- throw new Error(`ntfy ${r.status}: ${await r.text()}`);
905
+ const tags = priority === "high" ? "rotating_light" : "bell";
906
+ const subs = ntfySubscribers.get(ntfy.topic)?.size ?? 0;
907
+ if (subs === 0)
908
+ throw new Error(`ntfy: no subscribers on topic '${ntfy.topic}' — is the app connected?`);
909
+ ntfyFanout(ntfy.topic, message, "Claude Notify", priorityMap[priority] ?? 3, tags);
831
910
  });
832
911
  }
833
912
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "An MCP server that lets AI agents (Claude, Cursor, etc.) reach you on any channel — desktop, Telegram, SMS, email — with two-way ask/reply, real-time inbox push, Do Not Disturb, idle gating, multi-session routing, and a one-page web UI for setup. Zero config code; configure once, agents call notify/ask.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
package/ui/public/app.js CHANGED
@@ -82,8 +82,8 @@ function populateForm() {
82
82
  const ntfy = config.ntfy ?? {};
83
83
  $("ntfy-enabled").checked = !!ntfy.enabled;
84
84
  $("ntfy-topic").value = ntfy.topic ?? "";
85
- $("ntfy-server").value = ntfy.serverUrl ?? "https://ntfy.sh";
86
- $("ntfy-token").value = ntfy.token ?? "";
85
+ const defaultUrl = `${location.protocol}//${location.hostname}:${location.port || (location.protocol === 'https:' ? 443 : 80)}`;
86
+ $("ntfy-server-url").value = ntfy.serverUrl || defaultUrl;
87
87
 
88
88
  // Discord
89
89
  const dc = config.discord ?? {};
@@ -159,8 +159,18 @@ function updateBadges() {
159
159
  sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
160
160
 
161
161
  const ntfyC = config.ntfy ?? {};
162
- setBadge("ntfy", ntfyC.enabled && ntfyC.topic ? "ok" : ntfyC.topic ? "warn" : "idle",
163
- ntfyC.enabled && ntfyC.topic ? "Configured" : ntfyC.topic ? "Disabled" : "Not configured");
162
+ if (ntfyC.topic) {
163
+ fetch(`/ntfy/${encodeURIComponent(ntfyC.topic)}/subscribers`).then(r => r.json()).then(d => {
164
+ const count = d.subscribers ?? 0;
165
+ if (ntfyC.enabled) {
166
+ setBadge("ntfy", count > 0 ? "ok" : "warn", count > 0 ? `${count} subscriber${count===1?"":"s"}` : "No subscribers");
167
+ } else {
168
+ setBadge("ntfy", "idle", count > 0 ? `Disabled (${count} connected)` : "Disabled");
169
+ }
170
+ }).catch(() => setBadge("ntfy", ntfyC.enabled ? "warn" : "idle", ntfyC.enabled ? "Configured" : "Disabled"));
171
+ } else {
172
+ setBadge("ntfy", "idle", "Not configured");
173
+ }
164
174
 
165
175
  const dcC = config.discord ?? {};
166
176
  setBadge("discord", dcC.enabled && dcC.webhookUrl ? "ok" : dcC.webhookUrl ? "warn" : "idle",
@@ -349,9 +359,14 @@ async function saveSms() {
349
359
  }
350
360
 
351
361
  async function saveNtfy() {
352
- await patch({ ntfy: { enabled: $("ntfy-enabled").checked, topic: $("ntfy-topic").value.trim(), serverUrl: $("ntfy-server").value.trim() || "https://ntfy.sh", token: $("ntfy-token").value.trim() } });
362
+ await patch({ ntfy: { enabled: $("ntfy-enabled").checked, topic: $("ntfy-topic").value.trim(), serverUrl: $("ntfy-server-url").value.trim() } });
353
363
  clearDirty("ntfy");
354
364
  }
365
+
366
+ function copyNtfyUrl() {
367
+ const url = $("ntfy-server-url").value.trim();
368
+ navigator.clipboard.writeText(url).then(() => toast("Server URL copied!", "ok")).catch(() => toast("Copy failed", "error"));
369
+ }
355
370
  async function saveDiscord() {
356
371
  await patch({ discord: { enabled: $("discord-enabled").checked, webhookUrl: $("discord-webhook").value.trim(), username: $("discord-username").value.trim() || "Claude Notify" } });
357
372
  clearDirty("discord");
@@ -226,17 +226,23 @@
226
226
  <button class="btn btn-sm btn-ghost" onclick="testChannel('ntfy')">Test</button>
227
227
  </div>
228
228
  <details class="guide">
229
- <summary>Set up ntfy (1 min, free, no account needed)</summary>
229
+ <summary>Set up ntfy (1 min, free, fully private)</summary>
230
230
  <ol>
231
231
  <li>Install the <a href="https://ntfy.sh" target="_blank">ntfy app</a> on your phone</li>
232
- <li>Pick any topic name (e.g. <code>my-claude-alerts-abc123</code>) keep it secret, it's your auth</li>
233
- <li>Subscribe to that topic in the app</li>
234
- <li>Paste the topic below and save</li>
232
+ <li>In the app: tap <b>+</b> <b>Use another server</b> enter the Server URL shown below</li>
233
+ <li>Pick a topic name and subscribe</li>
234
+ <li>Paste the same topic below and save</li>
235
235
  </ol>
236
236
  </details>
237
- <div class="fg"><label>Topic</label><input type="text" id="ntfy-topic" placeholder="my-claude-alerts-abc123" oninput="markDirty('ntfy')"></div>
238
- <div class="fg"><label>Server URL</label><input type="text" id="ntfy-server" placeholder="https://ntfy.sh" oninput="markDirty('ntfy')"><span class="hint">Leave default for ntfy.sh, or your self-hosted URL</span></div>
239
- <div class="fg"><label>Token (optional)</label><input type="password" id="ntfy-token" placeholder="tk_…" oninput="markDirty('ntfy')"><span class="hint">Only needed for private self-hosted servers</span></div>
237
+ <div class="fg">
238
+ <label>Server URL (for ntfy app)</label>
239
+ <div style="display:flex;gap:6px">
240
+ <input type="text" id="ntfy-server-url" placeholder="http://73.223.191.86:3737" oninput="markDirty('ntfy')" style="flex:1;font-family:monospace;font-size:12px">
241
+ <button class="btn btn-sm btn-ghost" onclick="copyNtfyUrl()">Copy</button>
242
+ </div>
243
+ <span class="hint">Paste into the ntfy app under "Use another server". Use your public IP if accessing remotely.</span>
244
+ </div>
245
+ <div class="fg"><label>Topic</label><input type="text" id="ntfy-topic" placeholder="Claude_Alerts" oninput="markDirty('ntfy')"></div>
240
246
  </div>
241
247
  </div>
242
248