omni-notify-mcp 1.3.4 → 1.3.5
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 +108 -29
- package/package.json +1 -1
- package/ui/public/app.js +21 -5
- package/ui/public/index.html +13 -7
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: ""
|
|
31
|
+
ntfy: { enabled: false, topic: "" },
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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.
|
|
3
|
+
"version": "1.3.5",
|
|
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,9 @@ function populateForm() {
|
|
|
82
82
|
const ntfy = config.ntfy ?? {};
|
|
83
83
|
$("ntfy-enabled").checked = !!ntfy.enabled;
|
|
84
84
|
$("ntfy-topic").value = ntfy.topic ?? "";
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
const ntfyUrl = `${location.protocol}//${location.hostname}:${location.port || (location.protocol === 'https:' ? 443 : 80)}/ntfy`;
|
|
86
|
+
const urlEl = document.getElementById("ntfy-server-url");
|
|
87
|
+
if (urlEl) urlEl.textContent = ntfyUrl;
|
|
87
88
|
|
|
88
89
|
// Discord
|
|
89
90
|
const dc = config.discord ?? {};
|
|
@@ -159,8 +160,18 @@ function updateBadges() {
|
|
|
159
160
|
sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
|
|
160
161
|
|
|
161
162
|
const ntfyC = config.ntfy ?? {};
|
|
162
|
-
|
|
163
|
-
ntfyC.
|
|
163
|
+
if (ntfyC.topic) {
|
|
164
|
+
fetch(`/ntfy/${encodeURIComponent(ntfyC.topic)}/subscribers`).then(r => r.json()).then(d => {
|
|
165
|
+
const count = d.subscribers ?? 0;
|
|
166
|
+
if (ntfyC.enabled) {
|
|
167
|
+
setBadge("ntfy", count > 0 ? "ok" : "warn", count > 0 ? `${count} subscriber${count===1?"":"s"}` : "No subscribers");
|
|
168
|
+
} else {
|
|
169
|
+
setBadge("ntfy", "idle", count > 0 ? `Disabled (${count} connected)` : "Disabled");
|
|
170
|
+
}
|
|
171
|
+
}).catch(() => setBadge("ntfy", ntfyC.enabled ? "warn" : "idle", ntfyC.enabled ? "Configured" : "Disabled"));
|
|
172
|
+
} else {
|
|
173
|
+
setBadge("ntfy", "idle", "Not configured");
|
|
174
|
+
}
|
|
164
175
|
|
|
165
176
|
const dcC = config.discord ?? {};
|
|
166
177
|
setBadge("discord", dcC.enabled && dcC.webhookUrl ? "ok" : dcC.webhookUrl ? "warn" : "idle",
|
|
@@ -349,9 +360,14 @@ async function saveSms() {
|
|
|
349
360
|
}
|
|
350
361
|
|
|
351
362
|
async function saveNtfy() {
|
|
352
|
-
await patch({ ntfy: { enabled: $("ntfy-enabled").checked, topic: $("ntfy-topic").value.trim()
|
|
363
|
+
await patch({ ntfy: { enabled: $("ntfy-enabled").checked, topic: $("ntfy-topic").value.trim() } });
|
|
353
364
|
clearDirty("ntfy");
|
|
354
365
|
}
|
|
366
|
+
|
|
367
|
+
function copyNtfyUrl() {
|
|
368
|
+
const url = document.getElementById("ntfy-server-url")?.textContent ?? "";
|
|
369
|
+
navigator.clipboard.writeText(url).then(() => toast("Server URL copied!", "ok")).catch(() => toast("Copy failed", "error"));
|
|
370
|
+
}
|
|
355
371
|
async function saveDiscord() {
|
|
356
372
|
await patch({ discord: { enabled: $("discord-enabled").checked, webhookUrl: $("discord-webhook").value.trim(), username: $("discord-username").value.trim() || "Claude Notify" } });
|
|
357
373
|
clearDirty("discord");
|
package/ui/public/index.html
CHANGED
|
@@ -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,
|
|
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>
|
|
233
|
-
<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"
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
<div class="fg">
|
|
238
|
+
<label>Your ntfy server URL</label>
|
|
239
|
+
<div style="display:flex;gap:6px;align-items:center">
|
|
240
|
+
<code id="ntfy-server-url" style="flex:1;background:#1a1a2e;border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;color:#a5b4fc;cursor:pointer" onclick="copyNtfyUrl()" title="Click to copy">loading…</code>
|
|
241
|
+
<button class="btn btn-sm btn-ghost" onclick="copyNtfyUrl()">Copy</button>
|
|
242
|
+
</div>
|
|
243
|
+
<span class="hint">Point the ntfy app at this URL — notifications stay on your network.</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
|
|