omni-notify-mcp 1.2.1 → 1.3.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/dist/channels/desktop.js +1 -1
- package/dist/channels/discord.js +20 -0
- package/dist/channels/ntfy.js +21 -0
- package/dist/channels/pushover.js +26 -0
- package/dist/channels/slack.js +25 -0
- package/dist/channels/teams.js +43 -0
- package/dist/index.js +5 -1
- package/dist/ui/server.js +305 -21
- package/package.json +1 -1
- package/ui/public/app.js +135 -8
- package/ui/public/index.html +133 -0
- package/ui/public/style.css +16 -0
package/dist/channels/desktop.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function speak(text, voice = DEFAULT_TTS_VOICE) {
|
|
|
36
36
|
if (process.platform === "win32") {
|
|
37
37
|
spawn("powershell", [
|
|
38
38
|
"-NoProfile", "-Command",
|
|
39
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
39
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
40
40
|
], { windowsHide: true, stdio: "ignore" });
|
|
41
41
|
}
|
|
42
42
|
else if (process.platform === "darwin") {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const COLOR_MAP = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
|
|
2
|
+
export async function sendDiscord(config, message, priority = "normal", title = "Claude Notify") {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const res = await fetch(config.webhookUrl, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "Content-Type": "application/json" },
|
|
8
|
+
body: JSON.stringify({
|
|
9
|
+
username: config.username ?? "Claude Notify",
|
|
10
|
+
embeds: [{
|
|
11
|
+
title,
|
|
12
|
+
description: message,
|
|
13
|
+
color: COLOR_MAP[priority] ?? COLOR_MAP.normal,
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
}],
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`Discord ${res.status}: ${await res.text()}`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PRIORITY_MAP = { low: 2, normal: 3, high: 5 };
|
|
2
|
+
export async function sendNtfy(config, message, priority = "normal", title = "Claude Notify") {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const base = (config.serverUrl ?? "https://ntfy.sh").replace(/\/$/, "");
|
|
6
|
+
const headers = {
|
|
7
|
+
"Content-Type": "text/plain",
|
|
8
|
+
"Title": title,
|
|
9
|
+
"Priority": String(PRIORITY_MAP[priority] ?? 3),
|
|
10
|
+
"Tags": priority === "high" ? "rotating_light" : "bell",
|
|
11
|
+
};
|
|
12
|
+
if (config.token)
|
|
13
|
+
headers["Authorization"] = `Bearer ${config.token}`;
|
|
14
|
+
const res = await fetch(`${base}/${encodeURIComponent(config.topic)}`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers,
|
|
17
|
+
body: message,
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`ntfy ${res.status}: ${await res.text()}`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const PRIORITY_MAP = { low: -1, normal: 0, high: 1 };
|
|
2
|
+
export async function sendPushover(config, message, priority = "normal", title = "Claude Notify") {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const params = new URLSearchParams({
|
|
6
|
+
token: config.appToken,
|
|
7
|
+
user: config.userKey,
|
|
8
|
+
message,
|
|
9
|
+
title,
|
|
10
|
+
priority: String(PRIORITY_MAP[priority] ?? 0),
|
|
11
|
+
sound: config.sound ?? "pushover",
|
|
12
|
+
});
|
|
13
|
+
if (config.device)
|
|
14
|
+
params.set("device", config.device);
|
|
15
|
+
// priority=1 (high) bypasses quiet hours on the device but doesn't require
|
|
16
|
+
// retry/expire like emergency (2). We deliberately avoid emergency mode to
|
|
17
|
+
// prevent infinite retries for agent notifications.
|
|
18
|
+
const res = await fetch("https://api.pushover.net/1/messages.json", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
21
|
+
body: params.toString(),
|
|
22
|
+
});
|
|
23
|
+
const json = await res.json();
|
|
24
|
+
if (json.status !== 1)
|
|
25
|
+
throw new Error(`Pushover error: ${JSON.stringify(json.errors ?? json)}`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const EMOJI_MAP = { low: "ℹ️", normal: "🔔", high: "🚨" };
|
|
2
|
+
export async function sendSlack(config, message, priority = "normal", title = "Claude Notify") {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const emoji = EMOJI_MAP[priority] ?? EMOJI_MAP.normal;
|
|
6
|
+
const res = await fetch(config.webhookUrl, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: { "Content-Type": "application/json" },
|
|
9
|
+
body: JSON.stringify({
|
|
10
|
+
text: `${emoji} *${title}*`,
|
|
11
|
+
blocks: [
|
|
12
|
+
{
|
|
13
|
+
type: "section",
|
|
14
|
+
text: { type: "mrkdwn", text: `${emoji} *${title}*\n${message}` },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: "context",
|
|
18
|
+
elements: [{ type: "mrkdwn", text: `Priority: ${priority} · <!date^${Math.floor(Date.now() / 1000)}^{time}|now>` }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
throw new Error(`Slack ${res.status}: ${await res.text()}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const COLOR_MAP = { low: "Default", normal: "Accent", high: "Attention" };
|
|
2
|
+
export async function sendTeams(config, message, priority = "normal", title = "Claude Notify") {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const res = await fetch(config.webhookUrl, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "Content-Type": "application/json" },
|
|
8
|
+
body: JSON.stringify({
|
|
9
|
+
type: "message",
|
|
10
|
+
attachments: [{
|
|
11
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
12
|
+
contentUrl: null,
|
|
13
|
+
content: {
|
|
14
|
+
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
15
|
+
type: "AdaptiveCard",
|
|
16
|
+
version: "1.2",
|
|
17
|
+
body: [
|
|
18
|
+
{
|
|
19
|
+
type: "TextBlock",
|
|
20
|
+
size: "Medium",
|
|
21
|
+
weight: "Bolder",
|
|
22
|
+
text: title,
|
|
23
|
+
color: COLOR_MAP[priority] ?? "Default",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "TextBlock",
|
|
27
|
+
text: message,
|
|
28
|
+
wrap: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: "TextBlock",
|
|
32
|
+
text: `Priority: ${priority} · ${new Date().toLocaleTimeString()}`,
|
|
33
|
+
isSubtle: true,
|
|
34
|
+
size: "Small",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
}],
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw new Error(`Teams ${res.status}: ${await res.text()}`);
|
|
43
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -177,7 +177,11 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
177
177
|
"decision, (c) something important happened the user needs to know right " +
|
|
178
178
|
"now. Idle/DND gating is handled server-side — fire notify and let the " +
|
|
179
179
|
"server decide routing. Err on the side of notifying: a wrong-call gets " +
|
|
180
|
-
"silently downgraded by idle gating; a missed notify costs the user hours
|
|
180
|
+
"silently downgraded by idle gating; a missed notify costs the user hours.\n\n" +
|
|
181
|
+
"ALWAYS echo the COMPLETE, UNTRUNCATED message in your chat output — never " +
|
|
182
|
+
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
|
+
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
|
+
"Say 'notif' or 'notification' if you need to refer to the act of notifying.",
|
|
181
185
|
});
|
|
182
186
|
// Thin proxy: forward a tool call to the HTTP server and return its content
|
|
183
187
|
// block array verbatim. Error shape matches what the SDK expects from tool
|
package/dist/ui/server.js
CHANGED
|
@@ -28,6 +28,10 @@ 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: "" },
|
|
32
|
+
discord: { enabled: false, webhookUrl: "", username: "Claude Notify" },
|
|
33
|
+
slack: { enabled: false, webhookUrl: "" },
|
|
34
|
+
teams: { enabled: false, webhookUrl: "" },
|
|
31
35
|
dnd: {
|
|
32
36
|
enabled: false, // manual toggle — when true, suppress all non-priority=high notifs
|
|
33
37
|
schedule: {
|
|
@@ -101,11 +105,19 @@ function maskSecrets(config) {
|
|
|
101
105
|
c.telegram.token = MASKED;
|
|
102
106
|
if (c.whatsapp?.apiToken)
|
|
103
107
|
c.whatsapp.apiToken = MASKED;
|
|
108
|
+
if (c.ntfy?.token)
|
|
109
|
+
c.ntfy.token = MASKED;
|
|
110
|
+
if (c.discord?.webhookUrl)
|
|
111
|
+
c.discord.webhookUrl = MASKED;
|
|
112
|
+
if (c.slack?.webhookUrl)
|
|
113
|
+
c.slack.webhookUrl = MASKED;
|
|
114
|
+
if (c.teams?.webhookUrl)
|
|
115
|
+
c.teams.webhookUrl = MASKED;
|
|
104
116
|
return c;
|
|
105
117
|
}
|
|
106
118
|
function mergePreservingSecrets(existing, update) {
|
|
107
119
|
const merged = { ...defaultConfig(), ...existing };
|
|
108
|
-
for (const section of ["desktop", "telegram", "whatsapp", "sms", "email", "dnd", "idle"]) {
|
|
120
|
+
for (const section of ["desktop", "telegram", "whatsapp", "sms", "email", "ntfy", "discord", "slack", "teams", "dnd", "idle"]) {
|
|
109
121
|
merged[section] = { ...(merged[section] || {}), ...(update[section] || {}) };
|
|
110
122
|
}
|
|
111
123
|
// Nested schedule inside dnd
|
|
@@ -125,6 +137,10 @@ function mergePreservingSecrets(existing, update) {
|
|
|
125
137
|
guard(["sms", "authToken"]);
|
|
126
138
|
guard(["telegram", "token"]);
|
|
127
139
|
guard(["whatsapp", "apiToken"]);
|
|
140
|
+
guard(["ntfy", "token"]);
|
|
141
|
+
guard(["discord", "webhookUrl"]);
|
|
142
|
+
guard(["slack", "webhookUrl"]);
|
|
143
|
+
guard(["teams", "webhookUrl"]);
|
|
128
144
|
return merged;
|
|
129
145
|
}
|
|
130
146
|
const app = express();
|
|
@@ -181,7 +197,7 @@ async function speakText(text, voice) {
|
|
|
181
197
|
if (process.platform === "win32") {
|
|
182
198
|
spawn("powershell", [
|
|
183
199
|
"-NoProfile", "-Command",
|
|
184
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
200
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
185
201
|
], { windowsHide: true, stdio: "ignore" });
|
|
186
202
|
}
|
|
187
203
|
else if (process.platform === "darwin") {
|
|
@@ -369,6 +385,80 @@ app.post("/api/test/email", async (_req, res) => {
|
|
|
369
385
|
res.status(500).json({ error: String(err) });
|
|
370
386
|
}
|
|
371
387
|
});
|
|
388
|
+
app.post("/api/test/ntfy", async (_req, res) => {
|
|
389
|
+
const cfg = loadConfig();
|
|
390
|
+
const ntfy = cfg.ntfy ?? {};
|
|
391
|
+
if (!ntfy.topic) {
|
|
392
|
+
res.status(400).json({ error: "Topic is required." });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const base = (ntfy.serverUrl ?? "https://ntfy.sh").replace(/\/$/, "");
|
|
397
|
+
const headers = {
|
|
398
|
+
"Content-Type": "text/plain", "Title": "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: "Test from Claude Notify — ntfy is working!" });
|
|
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}'` });
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
res.status(500).json({ error: String(err) });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
app.post("/api/test/discord", async (_req, res) => {
|
|
412
|
+
const cfg = loadConfig();
|
|
413
|
+
const dc = cfg.discord ?? {};
|
|
414
|
+
if (!dc.webhookUrl) {
|
|
415
|
+
res.status(400).json({ error: "Webhook URL is required." });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const r = await fetch(dc.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: dc.username ?? "Claude Notify", embeds: [{ title: "Claude Notify — test", description: "Test from Claude Notify — Discord is working!", color: 0x7c6dfa, timestamp: new Date().toISOString() }] }) });
|
|
420
|
+
if (!r.ok)
|
|
421
|
+
throw new Error(`Discord ${r.status}: ${await r.text()}`);
|
|
422
|
+
res.json({ ok: true, message: "Discord message sent!" });
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
res.status(500).json({ error: String(err) });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
app.post("/api/test/slack", async (_req, res) => {
|
|
429
|
+
const cfg = loadConfig();
|
|
430
|
+
const sl = cfg.slack ?? {};
|
|
431
|
+
if (!sl.webhookUrl) {
|
|
432
|
+
res.status(400).json({ error: "Webhook URL is required." });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const r = await fetch(sl.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "🔔 *Claude Notify — test*\nTest from Claude Notify — Slack is working!" }) });
|
|
437
|
+
if (!r.ok)
|
|
438
|
+
throw new Error(`Slack ${r.status}: ${await r.text()}`);
|
|
439
|
+
res.json({ ok: true, message: "Slack message sent!" });
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
res.status(500).json({ error: String(err) });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
app.post("/api/test/teams", async (_req, res) => {
|
|
446
|
+
const cfg = loadConfig();
|
|
447
|
+
const tm = cfg.teams ?? {};
|
|
448
|
+
if (!tm.webhookUrl) {
|
|
449
|
+
res.status(400).json({ error: "Webhook URL is required." });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const r = await fetch(tm.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "message", attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", contentUrl: null, content: { $schema: "http://adaptivecards.io/schemas/adaptive-card.json", type: "AdaptiveCard", version: "1.2", body: [{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify — test" }, { type: "TextBlock", text: "Test from Claude Notify — Teams is working!", wrap: true }] } }] }) });
|
|
454
|
+
if (!r.ok)
|
|
455
|
+
throw new Error(`Teams ${r.status}: ${await r.text()}`);
|
|
456
|
+
res.json({ ok: true, message: "Teams message sent!" });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
res.status(500).json({ error: String(err) });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
372
462
|
// ── Google ADC auto-setup ─────────────────────────────────────────────────────
|
|
373
463
|
async function adcEmail() {
|
|
374
464
|
if (!existsSync(ADC_PATH))
|
|
@@ -558,11 +648,29 @@ app.get("/api/sessions", (_req, res) => {
|
|
|
558
648
|
tag: s.tag,
|
|
559
649
|
clientName: s.clientName,
|
|
560
650
|
clientVersion: s.clientVersion,
|
|
651
|
+
workspaceName: s.workspaceName,
|
|
561
652
|
host: s.host,
|
|
562
653
|
connectedAt: s.connectedAt,
|
|
654
|
+
lastSeen: s.lastSeen,
|
|
563
655
|
}));
|
|
564
656
|
res.json({ sessions: list });
|
|
565
657
|
});
|
|
658
|
+
app.delete("/api/sessions/:clientId", (req, res) => {
|
|
659
|
+
const { clientId } = req.params;
|
|
660
|
+
const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
|
|
661
|
+
if (!entry) {
|
|
662
|
+
res.status(404).json({ error: "not found" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const [sessionId] = entry;
|
|
666
|
+
try {
|
|
667
|
+
httpTransports[sessionId]?.close();
|
|
668
|
+
}
|
|
669
|
+
catch { /* ignore */ }
|
|
670
|
+
delete httpTransports[sessionId];
|
|
671
|
+
delete sessions[sessionId];
|
|
672
|
+
res.json({ ok: true });
|
|
673
|
+
});
|
|
566
674
|
app.get("/api/logs", (req, res) => {
|
|
567
675
|
res.setHeader("Content-Type", "text/event-stream");
|
|
568
676
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -594,7 +702,14 @@ async function sendNotification(message, priority, client) {
|
|
|
594
702
|
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
595
703
|
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
596
704
|
let desktopOnlyMode = false;
|
|
597
|
-
|
|
705
|
+
// If the web UI is open and the user is actively watching it, skip remote channels.
|
|
706
|
+
if (priority !== "high" && isUiActivelyOpen()) {
|
|
707
|
+
if (cfg.idle?.alwaysDesktopWhenActive !== false && cfg.desktop?.enabled) {
|
|
708
|
+
desktopOnlyMode = true;
|
|
709
|
+
log("·", "ui", `UI visible — desktop-only`, client);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!desktopOnlyMode && priority !== "high" && !inTelegramConvo && cfg.idle?.enabled !== false) {
|
|
598
713
|
const idleSecs = getOsIdleSeconds();
|
|
599
714
|
const threshold = cfg.idle?.thresholdSeconds ?? 120;
|
|
600
715
|
const userIsActive = idleSecs >= 0 && idleSecs < threshold;
|
|
@@ -693,6 +808,107 @@ async function sendNotification(message, priority, client) {
|
|
|
693
808
|
to: email.to, subject: "Claude Notify", text: message });
|
|
694
809
|
});
|
|
695
810
|
}
|
|
811
|
+
// ntfy
|
|
812
|
+
if (!desktopOnlyMode) {
|
|
813
|
+
const ntfy = cfg.ntfy ?? {};
|
|
814
|
+
if (ntfy.enabled && ntfy.topic) {
|
|
815
|
+
await send("ntfy", async () => {
|
|
816
|
+
const base = (ntfy.serverUrl ?? "https://ntfy.sh").replace(/\/$/, "");
|
|
817
|
+
const priorityMap = { low: 2, normal: 3, high: 5 };
|
|
818
|
+
const headers = {
|
|
819
|
+
"Content-Type": "text/plain",
|
|
820
|
+
"Title": "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: message,
|
|
828
|
+
});
|
|
829
|
+
if (!r.ok)
|
|
830
|
+
throw new Error(`ntfy ${r.status}: ${await r.text()}`);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Discord
|
|
835
|
+
if (!desktopOnlyMode) {
|
|
836
|
+
const dc = cfg.discord ?? {};
|
|
837
|
+
if (dc.enabled && dc.webhookUrl) {
|
|
838
|
+
await send("discord", async () => {
|
|
839
|
+
const colorMap = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
|
|
840
|
+
const r = await fetch(dc.webhookUrl, {
|
|
841
|
+
method: "POST",
|
|
842
|
+
headers: { "Content-Type": "application/json" },
|
|
843
|
+
body: JSON.stringify({
|
|
844
|
+
username: dc.username ?? "Claude Notify",
|
|
845
|
+
embeds: [{
|
|
846
|
+
title: "Claude Notify",
|
|
847
|
+
description: message,
|
|
848
|
+
color: colorMap[priority] ?? colorMap.normal,
|
|
849
|
+
timestamp: new Date().toISOString(),
|
|
850
|
+
}],
|
|
851
|
+
}),
|
|
852
|
+
});
|
|
853
|
+
if (!r.ok)
|
|
854
|
+
throw new Error(`Discord ${r.status}: ${await r.text()}`);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Slack
|
|
859
|
+
if (!desktopOnlyMode) {
|
|
860
|
+
const sl = cfg.slack ?? {};
|
|
861
|
+
if (sl.enabled && sl.webhookUrl) {
|
|
862
|
+
await send("slack", async () => {
|
|
863
|
+
const emojiMap = { low: "ℹ️", normal: "🔔", high: "🚨" };
|
|
864
|
+
const emoji = emojiMap[priority] ?? emojiMap.normal;
|
|
865
|
+
const r = await fetch(sl.webhookUrl, {
|
|
866
|
+
method: "POST",
|
|
867
|
+
headers: { "Content-Type": "application/json" },
|
|
868
|
+
body: JSON.stringify({
|
|
869
|
+
text: `${emoji} *Claude Notify*`,
|
|
870
|
+
blocks: [
|
|
871
|
+
{ type: "section", text: { type: "mrkdwn", text: `${emoji} *Claude Notify*\n${message}` } },
|
|
872
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${priority}` }] },
|
|
873
|
+
],
|
|
874
|
+
}),
|
|
875
|
+
});
|
|
876
|
+
if (!r.ok)
|
|
877
|
+
throw new Error(`Slack ${r.status}: ${await r.text()}`);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// Teams
|
|
882
|
+
if (!desktopOnlyMode) {
|
|
883
|
+
const tm = cfg.teams ?? {};
|
|
884
|
+
if (tm.enabled && tm.webhookUrl) {
|
|
885
|
+
await send("teams", async () => {
|
|
886
|
+
const colorMap = { low: "Default", normal: "Accent", high: "Attention" };
|
|
887
|
+
const r = await fetch(tm.webhookUrl, {
|
|
888
|
+
method: "POST",
|
|
889
|
+
headers: { "Content-Type": "application/json" },
|
|
890
|
+
body: JSON.stringify({
|
|
891
|
+
type: "message",
|
|
892
|
+
attachments: [{
|
|
893
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
894
|
+
contentUrl: null,
|
|
895
|
+
content: {
|
|
896
|
+
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
897
|
+
type: "AdaptiveCard", version: "1.2",
|
|
898
|
+
body: [
|
|
899
|
+
{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[priority] ?? "Default" },
|
|
900
|
+
{ type: "TextBlock", text: message, wrap: true },
|
|
901
|
+
{ type: "TextBlock", text: `Priority: ${priority}`, isSubtle: true, size: "Small" },
|
|
902
|
+
],
|
|
903
|
+
},
|
|
904
|
+
}],
|
|
905
|
+
}),
|
|
906
|
+
});
|
|
907
|
+
if (!r.ok)
|
|
908
|
+
throw new Error(`Teams ${r.status}: ${await r.text()}`);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
696
912
|
return [
|
|
697
913
|
results.length ? `Sent via: ${results.join(", ")}` : null,
|
|
698
914
|
errors.length ? `Errors: ${errors.join("; ")}` : null,
|
|
@@ -772,6 +988,28 @@ let lastUserMessageId;
|
|
|
772
988
|
// goes quiet.
|
|
773
989
|
let lastTelegramInboundAt = 0;
|
|
774
990
|
const TELEGRAM_CONVO_TTL_MS = 5 * 60 * 1000;
|
|
991
|
+
// Page visibility: the web UI reports when it becomes visible/hidden so the
|
|
992
|
+
// server can skip external channels while the user is actively watching the UI.
|
|
993
|
+
let uiVisibleAt = 0; // last time UI reported visible
|
|
994
|
+
let uiHiddenAt = 0; // last time UI reported hidden (0 = never seen)
|
|
995
|
+
const UI_VISIBLE_TTL_MS = 30_000; // if no heartbeat for 30s, treat as unknown
|
|
996
|
+
app.post("/api/ui/visibility", (req, res) => {
|
|
997
|
+
const { visible } = req.body ?? {};
|
|
998
|
+
if (visible) {
|
|
999
|
+
uiVisibleAt = Date.now();
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
uiHiddenAt = Date.now();
|
|
1003
|
+
}
|
|
1004
|
+
res.json({ ok: true });
|
|
1005
|
+
});
|
|
1006
|
+
function isUiActivelyOpen() {
|
|
1007
|
+
if (uiVisibleAt === 0)
|
|
1008
|
+
return false; // never reported
|
|
1009
|
+
if (uiHiddenAt > uiVisibleAt)
|
|
1010
|
+
return false; // last report was hidden
|
|
1011
|
+
return Date.now() - uiVisibleAt < UI_VISIBLE_TTL_MS;
|
|
1012
|
+
}
|
|
775
1013
|
// Session tagging: a session may declare a tag (e.g. "alphawave") when it
|
|
776
1014
|
// connects to /mcp?tag=alphawave. Telegram messages starting with "@<tag>"
|
|
777
1015
|
// are routed only to sessions with that exact tag (tag prefix stripped).
|
|
@@ -1155,13 +1393,18 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1155
1393
|
Use ONLY for catastrophic findings or decisions that block
|
|
1156
1394
|
progress. Misuse will train the user to ignore your notifs.
|
|
1157
1395
|
|
|
1158
|
-
3. Echo the
|
|
1159
|
-
as sending it through 'notify'. The user may be reading the
|
|
1160
|
-
directly; don't rely on them checking their phone / email.
|
|
1396
|
+
3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
|
|
1397
|
+
output as well as sending it through 'notify'. The user may be reading the
|
|
1398
|
+
terminal directly; don't rely on them checking their phone / email. Do NOT
|
|
1399
|
+
shorten, summarise, or cut off the message with "…" in your chat output —
|
|
1400
|
+
show every word exactly as sent.
|
|
1161
1401
|
|
|
1162
1402
|
4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
|
|
1163
|
-
'email', etc. in your messages
|
|
1164
|
-
user has already configured
|
|
1403
|
+
'email', 'desktop', etc. in your messages or in your chat output — those are
|
|
1404
|
+
server delivery details the user has already configured and the client has
|
|
1405
|
+
no business surfacing. Do NOT echo "Sent via: <channel list>" or any
|
|
1406
|
+
variant of it. Just say 'notif' or 'notification' if you need to refer to
|
|
1407
|
+
the act of notifying.
|
|
1165
1408
|
|
|
1166
1409
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
1167
1410
|
the 'notify' response, via 'poll', via 'wait_for_inbox', via
|
|
@@ -1256,7 +1499,9 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1256
1499
|
MCP client is broken, you owe them the bypass — not an excuse.
|
|
1257
1500
|
`.trim();
|
|
1258
1501
|
function createMcpServer(clientId, sessionTag) {
|
|
1259
|
-
const
|
|
1502
|
+
const identity = sessionTag ? `@${sessionTag}` : clientId;
|
|
1503
|
+
const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
|
|
1504
|
+
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
|
|
1260
1505
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
1261
1506
|
"Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
|
|
1262
1507
|
"skip the call if the user is active (unless priority='high'). " +
|
|
@@ -1500,14 +1745,16 @@ function sessionDisplay(s) {
|
|
|
1500
1745
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1501
1746
|
}
|
|
1502
1747
|
app.all("/mcp", async (req, res) => {
|
|
1748
|
+
console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
|
|
1503
1749
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1504
1750
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
1505
|
-
|
|
1751
|
+
const transport = httpTransports[existingSessionId];
|
|
1752
|
+
await transport.handleRequest(req, res, req.body);
|
|
1506
1753
|
// Lazy-populate clientInfo after initialize lands on an existing session.
|
|
1507
1754
|
const meta = sessions[existingSessionId];
|
|
1508
1755
|
if (meta)
|
|
1509
1756
|
meta.lastSeen = Date.now();
|
|
1510
|
-
const mcpServer = httpTransports[existingSessionId]
|
|
1757
|
+
const mcpServer = httpTransports[existingSessionId]?.__mcpServer;
|
|
1511
1758
|
if (meta && !meta.clientName && mcpServer?.getClientVersion) {
|
|
1512
1759
|
try {
|
|
1513
1760
|
const info = mcpServer.getClientVersion();
|
|
@@ -1520,12 +1767,19 @@ app.all("/mcp", async (req, res) => {
|
|
|
1520
1767
|
}
|
|
1521
1768
|
return;
|
|
1522
1769
|
}
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1770
|
+
// Auto-reconnect path. If the client presents a session id we don't know
|
|
1771
|
+
// about AND the request body is a fresh `initialize`, adopt the stale id
|
|
1772
|
+
// instead of 404-ing. This covers the "server was restarted while Claude
|
|
1773
|
+
// Code was open" case: clients that cache the session id (claude-code#27142)
|
|
1774
|
+
// would otherwise stay ghost until the human manually reloaded the window.
|
|
1775
|
+
// A non-initialize request with an unknown id still gets 404 — the client
|
|
1776
|
+
// is expected to reinitialize in response.
|
|
1777
|
+
const bodyIsInitialize = req.method === "POST" &&
|
|
1778
|
+
req.body &&
|
|
1779
|
+
(Array.isArray(req.body)
|
|
1780
|
+
? req.body.some((m) => m?.method === "initialize")
|
|
1781
|
+
: req.body.method === "initialize");
|
|
1782
|
+
if (existingSessionId && !bodyIsInitialize) {
|
|
1529
1783
|
res.status(404).json({
|
|
1530
1784
|
jsonrpc: "2.0",
|
|
1531
1785
|
error: { code: -32000, message: "Session not found — reinitialize" },
|
|
@@ -1535,13 +1789,40 @@ app.all("/mcp", async (req, res) => {
|
|
|
1535
1789
|
}
|
|
1536
1790
|
const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
|
|
1537
1791
|
const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
|
|
1538
|
-
|
|
1792
|
+
// If the client brought a stale id on an initialize, reuse it so the client
|
|
1793
|
+
// never has to swap ids. Otherwise mint a fresh one.
|
|
1794
|
+
const newSessionId = existingSessionId ?? randomUUID();
|
|
1539
1795
|
const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
|
|
1540
1796
|
const port = req.socket.remotePort;
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
const
|
|
1797
|
+
// Pull clientInfo and workspace from the initialize body immediately so the
|
|
1798
|
+
// pill shows a readable name from the start.
|
|
1799
|
+
const initBody = Array.isArray(req.body)
|
|
1800
|
+
? req.body.find((m) => m?.method === "initialize")
|
|
1801
|
+
: req.body;
|
|
1802
|
+
const earlyClientName = initBody?.params?.clientInfo?.name;
|
|
1803
|
+
// Prefer the workspace folder name (e.g. "AlphaWave", "notify-mcp-src") over
|
|
1804
|
+
// the generic client name ("claude-code"). workspaceFolders[0].name is set by
|
|
1805
|
+
// Claude Code and Cursor; rootUri is the fallback.
|
|
1806
|
+
const workspaceFolders = initBody?.params?.workspaceFolders;
|
|
1807
|
+
const rootUri = initBody?.params?.rootUri ?? initBody?.params?.root_uri;
|
|
1808
|
+
const workspaceName = workspaceFolders?.[0]?.name ||
|
|
1809
|
+
(rootUri ? rootUri.replace(/\\/g, "/").split("/").filter(Boolean).pop() : undefined);
|
|
1810
|
+
// Build a distinguishable client id: tag wins if set; workspace name next;
|
|
1811
|
+
// then clientInfo.name; otherwise use host+port. If the base id is already
|
|
1812
|
+
// taken, append -2, -3, … so two windows on the same project still show up.
|
|
1813
|
+
const baseId = sessionTag
|
|
1814
|
+
?? workspaceName
|
|
1815
|
+
?? earlyClientName
|
|
1544
1816
|
?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
|
|
1817
|
+
// Exclude the session being re-adopted from the "taken" set — it's about to
|
|
1818
|
+
// be replaced, so its old clientId should be available for reuse.
|
|
1819
|
+
const adoptingId = existingSessionId && bodyIsInitialize ? existingSessionId : undefined;
|
|
1820
|
+
const takenIds = new Set(Object.entries(sessions)
|
|
1821
|
+
.filter(([sid]) => sid !== adoptingId)
|
|
1822
|
+
.map(([, s]) => s.clientId));
|
|
1823
|
+
let clientId = baseId;
|
|
1824
|
+
for (let n = 2; takenIds.has(clientId); n++)
|
|
1825
|
+
clientId = `${baseId}-${n}`;
|
|
1545
1826
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
|
|
1546
1827
|
transport.onclose = () => {
|
|
1547
1828
|
if (transport.sessionId) {
|
|
@@ -1560,6 +1841,9 @@ app.all("/mcp", async (req, res) => {
|
|
|
1560
1841
|
const now = Date.now();
|
|
1561
1842
|
sessions[transport.sessionId] = {
|
|
1562
1843
|
clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
|
|
1844
|
+
clientName: earlyClientName,
|
|
1845
|
+
clientVersion: initBody?.params?.clientInfo?.version,
|
|
1846
|
+
workspaceName,
|
|
1563
1847
|
};
|
|
1564
1848
|
}
|
|
1565
1849
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
@@ -71,6 +71,29 @@ function populateForm() {
|
|
|
71
71
|
$("sms-from").value = sms.from ?? "";
|
|
72
72
|
$("sms-to").value = sms.to ?? "";
|
|
73
73
|
|
|
74
|
+
// ntfy
|
|
75
|
+
const ntfy = config.ntfy ?? {};
|
|
76
|
+
$("ntfy-enabled").checked = !!ntfy.enabled;
|
|
77
|
+
$("ntfy-topic").value = ntfy.topic ?? "";
|
|
78
|
+
$("ntfy-server").value = ntfy.serverUrl ?? "https://ntfy.sh";
|
|
79
|
+
$("ntfy-token").value = ntfy.token ?? "";
|
|
80
|
+
|
|
81
|
+
// Discord
|
|
82
|
+
const dc = config.discord ?? {};
|
|
83
|
+
$("discord-enabled").checked = !!dc.enabled;
|
|
84
|
+
$("discord-webhook").value = dc.webhookUrl ?? "";
|
|
85
|
+
$("discord-username").value = dc.username ?? "";
|
|
86
|
+
|
|
87
|
+
// Slack
|
|
88
|
+
const sl = config.slack ?? {};
|
|
89
|
+
$("slack-enabled").checked = !!sl.enabled;
|
|
90
|
+
$("slack-webhook").value = sl.webhookUrl ?? "";
|
|
91
|
+
|
|
92
|
+
// Teams
|
|
93
|
+
const tm = config.teams ?? {};
|
|
94
|
+
$("teams-enabled").checked = !!tm.enabled;
|
|
95
|
+
$("teams-webhook").value = tm.webhookUrl ?? "";
|
|
96
|
+
|
|
74
97
|
// DND
|
|
75
98
|
const dnd = config.dnd ?? {};
|
|
76
99
|
$("dnd-enabled").checked = !!dnd.enabled;
|
|
@@ -128,6 +151,22 @@ function updateBadges() {
|
|
|
128
151
|
sms.enabled && smsReady ? "ok" : smsReady ? "warn" : sms.accountSid ? "warn" : "idle",
|
|
129
152
|
sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
|
|
130
153
|
|
|
154
|
+
const ntfyC = config.ntfy ?? {};
|
|
155
|
+
setBadge("ntfy", ntfyC.enabled && ntfyC.topic ? "ok" : ntfyC.topic ? "warn" : "idle",
|
|
156
|
+
ntfyC.enabled && ntfyC.topic ? "Configured" : ntfyC.topic ? "Disabled" : "Not configured");
|
|
157
|
+
|
|
158
|
+
const dcC = config.discord ?? {};
|
|
159
|
+
setBadge("discord", dcC.enabled && dcC.webhookUrl ? "ok" : dcC.webhookUrl ? "warn" : "idle",
|
|
160
|
+
dcC.enabled && dcC.webhookUrl ? "Configured" : dcC.webhookUrl ? "Disabled" : "Not configured");
|
|
161
|
+
|
|
162
|
+
const slC = config.slack ?? {};
|
|
163
|
+
setBadge("slack", slC.enabled && slC.webhookUrl ? "ok" : slC.webhookUrl ? "warn" : "idle",
|
|
164
|
+
slC.enabled && slC.webhookUrl ? "Configured" : slC.webhookUrl ? "Disabled" : "Not configured");
|
|
165
|
+
|
|
166
|
+
const tmC = config.teams ?? {};
|
|
167
|
+
setBadge("teams", tmC.enabled && tmC.webhookUrl ? "ok" : tmC.webhookUrl ? "warn" : "idle",
|
|
168
|
+
tmC.enabled && tmC.webhookUrl ? "Configured" : tmC.webhookUrl ? "Disabled" : "Not configured");
|
|
169
|
+
|
|
131
170
|
// DND badge: "Active" (red), "Scheduled" (warn), or "Off" (idle)
|
|
132
171
|
const dnd = config.dnd ?? {};
|
|
133
172
|
const sched = dnd.schedule ?? {};
|
|
@@ -219,6 +258,18 @@ async function toggleEmailEnabled() {
|
|
|
219
258
|
async function toggleSmsEnabled() {
|
|
220
259
|
await patch({ sms: { enabled: $("sms-enabled").checked } });
|
|
221
260
|
}
|
|
261
|
+
async function toggleNtfyEnabled() {
|
|
262
|
+
await patch({ ntfy: { enabled: $("ntfy-enabled").checked } });
|
|
263
|
+
}
|
|
264
|
+
async function toggleDiscordEnabled() {
|
|
265
|
+
await patch({ discord: { enabled: $("discord-enabled").checked } });
|
|
266
|
+
}
|
|
267
|
+
async function toggleSlackEnabled() {
|
|
268
|
+
await patch({ slack: { enabled: $("slack-enabled").checked } });
|
|
269
|
+
}
|
|
270
|
+
async function toggleTeamsEnabled() {
|
|
271
|
+
await patch({ teams: { enabled: $("teams-enabled").checked } });
|
|
272
|
+
}
|
|
222
273
|
|
|
223
274
|
async function saveTelegram() {
|
|
224
275
|
await patch({
|
|
@@ -290,6 +341,23 @@ async function saveSms() {
|
|
|
290
341
|
clearDirty("sms");
|
|
291
342
|
}
|
|
292
343
|
|
|
344
|
+
async function saveNtfy() {
|
|
345
|
+
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() } });
|
|
346
|
+
clearDirty("ntfy");
|
|
347
|
+
}
|
|
348
|
+
async function saveDiscord() {
|
|
349
|
+
await patch({ discord: { enabled: $("discord-enabled").checked, webhookUrl: $("discord-webhook").value.trim(), username: $("discord-username").value.trim() || "Claude Notify" } });
|
|
350
|
+
clearDirty("discord");
|
|
351
|
+
}
|
|
352
|
+
async function saveSlack() {
|
|
353
|
+
await patch({ slack: { enabled: $("slack-enabled").checked, webhookUrl: $("slack-webhook").value.trim() } });
|
|
354
|
+
clearDirty("slack");
|
|
355
|
+
}
|
|
356
|
+
async function saveTeams() {
|
|
357
|
+
await patch({ teams: { enabled: $("teams-enabled").checked, webhookUrl: $("teams-webhook").value.trim() } });
|
|
358
|
+
clearDirty("teams");
|
|
359
|
+
}
|
|
360
|
+
|
|
293
361
|
async function patch(update) {
|
|
294
362
|
try {
|
|
295
363
|
const res = await fetch("/api/config", {
|
|
@@ -535,6 +603,38 @@ function toast(msg, type = "ok") {
|
|
|
535
603
|
|
|
536
604
|
function $(id) { return document.getElementById(id); }
|
|
537
605
|
|
|
606
|
+
// ── Page visibility reporting ─────────────────────────────────────────────
|
|
607
|
+
// Tell the server when this tab is focused so it can skip external channels
|
|
608
|
+
// while the user is actively watching the UI.
|
|
609
|
+
|
|
610
|
+
function reportVisibility(visible) {
|
|
611
|
+
fetch("/api/ui/visibility", {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: { "Content-Type": "application/json" },
|
|
614
|
+
body: JSON.stringify({ visible }),
|
|
615
|
+
}).catch(() => {});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let visibilityHeartbeat;
|
|
619
|
+
function startVisibilityHeartbeat() {
|
|
620
|
+
clearInterval(visibilityHeartbeat);
|
|
621
|
+
visibilityHeartbeat = setInterval(() => {
|
|
622
|
+
if (!document.hidden) reportVisibility(true);
|
|
623
|
+
}, 15000);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
document.addEventListener("visibilitychange", () => {
|
|
627
|
+
reportVisibility(!document.hidden);
|
|
628
|
+
if (!document.hidden) startVisibilityHeartbeat();
|
|
629
|
+
else clearInterval(visibilityHeartbeat);
|
|
630
|
+
});
|
|
631
|
+
window.addEventListener("focus", () => { reportVisibility(true); startVisibilityHeartbeat(); });
|
|
632
|
+
window.addEventListener("blur", () => reportVisibility(false));
|
|
633
|
+
|
|
634
|
+
// Report on load and start heartbeat
|
|
635
|
+
reportVisibility(!document.hidden);
|
|
636
|
+
if (!document.hidden) startVisibilityHeartbeat();
|
|
637
|
+
|
|
538
638
|
function copyText(el) {
|
|
539
639
|
const text = el.textContent.replace(" 📋", "").trim();
|
|
540
640
|
navigator.clipboard.writeText(text).then(() => {
|
|
@@ -613,39 +713,66 @@ function renderLogEntry(raw) {
|
|
|
613
713
|
if (atBottom) panel.scrollTop = panel.scrollHeight;
|
|
614
714
|
}
|
|
615
715
|
|
|
716
|
+
function sessionStatus(lastSeen) {
|
|
717
|
+
const age = Date.now() - lastSeen;
|
|
718
|
+
if (age < 35_000) return "live";
|
|
719
|
+
if (age < 95_000) return "idle";
|
|
720
|
+
return "stale";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function dismissSession(clientId) {
|
|
724
|
+
await fetch(`/api/sessions/${encodeURIComponent(clientId)}`, { method: "DELETE" });
|
|
725
|
+
refreshSessions();
|
|
726
|
+
}
|
|
727
|
+
|
|
616
728
|
async function refreshSessions() {
|
|
617
729
|
try {
|
|
618
730
|
const res = await fetch("/api/sessions");
|
|
619
731
|
if (!res.ok) return;
|
|
620
732
|
const { sessions } = await res.json();
|
|
621
733
|
const bar = $("session-pills");
|
|
622
|
-
// Build a set of desired pills (keyed by clientId). Keep the "All" pill.
|
|
623
734
|
const existing = new Map();
|
|
624
|
-
bar.querySelectorAll(".pill").forEach(p =>
|
|
735
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
736
|
+
if (p.dataset.client !== "") existing.set(p.dataset.client, p);
|
|
737
|
+
});
|
|
625
738
|
const desired = new Set([""]);
|
|
626
739
|
for (const s of sessions) desired.add(s.clientId);
|
|
627
|
-
|
|
740
|
+
|
|
741
|
+
// Remove pills for sessions the server no longer knows about.
|
|
628
742
|
for (const [id, el] of existing) {
|
|
629
743
|
if (!desired.has(id)) el.remove();
|
|
630
744
|
}
|
|
631
|
-
|
|
745
|
+
|
|
746
|
+
// Add or update pills.
|
|
632
747
|
for (const s of sessions) {
|
|
748
|
+
const status = sessionStatus(s.lastSeen);
|
|
749
|
+
const label = s.tag ? `@${s.tag}` : s.clientId;
|
|
750
|
+
const title = [s.workspaceName ?? s.clientName, s.host, `last seen ${Math.round((Date.now() - s.lastSeen) / 1000)}s ago`].filter(Boolean).join(" · ");
|
|
751
|
+
|
|
633
752
|
if (!existing.has(s.clientId)) {
|
|
634
753
|
const btn = document.createElement("button");
|
|
635
754
|
btn.className = "pill";
|
|
636
755
|
btn.dataset.client = s.clientId;
|
|
637
|
-
btn.textContent = s.tag ? `@${s.tag}` : s.clientId;
|
|
638
|
-
btn.title = [s.clientName, s.host].filter(Boolean).join(" · ");
|
|
639
756
|
btn.onclick = () => selectLogFilter(s.clientId);
|
|
640
757
|
bar.appendChild(btn);
|
|
758
|
+
existing.set(s.clientId, btn);
|
|
641
759
|
}
|
|
760
|
+
|
|
761
|
+
const btn = existing.get(s.clientId);
|
|
762
|
+
btn.title = title;
|
|
763
|
+
btn.innerHTML =
|
|
764
|
+
`<span class="pill-dot pill-dot-${status}"></span>` +
|
|
765
|
+
`<span class="pill-label">${label}</span>` +
|
|
766
|
+
(status === "stale"
|
|
767
|
+
? `<span class="pill-dismiss" title="Remove" onclick="event.stopPropagation();dismissSession('${s.clientId.replace(/'/g,"\\'")}')">×</span>`
|
|
768
|
+
: "");
|
|
642
769
|
}
|
|
770
|
+
|
|
643
771
|
// If the currently-selected client disconnected, fall back to "All".
|
|
644
772
|
if (logFilterClient && !desired.has(logFilterClient)) {
|
|
645
773
|
selectLogFilter("");
|
|
646
774
|
} else {
|
|
647
|
-
|
|
648
|
-
bar.querySelectorAll(".pill").forEach(p => {
|
|
775
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
649
776
|
p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
|
|
650
777
|
});
|
|
651
778
|
}
|
package/ui/public/index.html
CHANGED
|
@@ -199,6 +199,139 @@
|
|
|
199
199
|
</div>
|
|
200
200
|
</div>
|
|
201
201
|
|
|
202
|
+
<!-- ── ntfy ─────────────────────────────────────────────────── -->
|
|
203
|
+
<div class="card" id="card-ntfy">
|
|
204
|
+
<div class="card-hd">
|
|
205
|
+
<div class="ch-meta">
|
|
206
|
+
<div class="channel-icon ntfy-icon">
|
|
207
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
|
208
|
+
</div>
|
|
209
|
+
<span class="ch-name">ntfy</span><span class="tag">Push</span>
|
|
210
|
+
</div>
|
|
211
|
+
<span class="badge badge-idle" id="badge-ntfy">Not configured</span>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="card-body">
|
|
214
|
+
<div class="actions">
|
|
215
|
+
<label class="toggle-wrap">
|
|
216
|
+
<input type="checkbox" id="ntfy-enabled" onchange="toggleNtfyEnabled()">
|
|
217
|
+
<span class="toggle"></span>
|
|
218
|
+
</label>
|
|
219
|
+
<span class="toggle-lbl">Enable</span>
|
|
220
|
+
<button class="btn btn-sm btn-primary" id="save-ntfy-btn" onclick="saveNtfy()">Save</button>
|
|
221
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('ntfy')">Test</button>
|
|
222
|
+
</div>
|
|
223
|
+
<details class="guide">
|
|
224
|
+
<summary>Set up ntfy (1 min, free, no account needed)</summary>
|
|
225
|
+
<ol>
|
|
226
|
+
<li>Install the <a href="https://ntfy.sh" target="_blank">ntfy app</a> on your phone</li>
|
|
227
|
+
<li>Pick any topic name (e.g. <code>my-claude-alerts-abc123</code>) — keep it secret, it's your auth</li>
|
|
228
|
+
<li>Subscribe to that topic in the app</li>
|
|
229
|
+
<li>Paste the topic below and save</li>
|
|
230
|
+
</ol>
|
|
231
|
+
</details>
|
|
232
|
+
<div class="fg"><label>Topic</label><input type="text" id="ntfy-topic" placeholder="my-claude-alerts-abc123" oninput="markDirty('ntfy')"></div>
|
|
233
|
+
<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>
|
|
234
|
+
<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>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<!-- ── Discord ───────────────────────────────────────────────── -->
|
|
239
|
+
<div class="card" id="card-discord">
|
|
240
|
+
<div class="card-hd">
|
|
241
|
+
<div class="ch-meta">
|
|
242
|
+
<div class="channel-icon discord-icon">
|
|
243
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><path d="M7.5 7.5c1-.5 2.5-.5 4.5-.5s3.5 0 4.5.5M7.5 16.5c1 .5 2.5.5 4.5.5s3.5 0 4.5-.5"/><path d="M3 3l4.5 1.5M21 3l-4.5 1.5M3 21l4.5-1.5M21 21l-4.5-1.5"/><path d="M3 3c0 9 0 12 9 15 9-3 9-6 9-15"/></svg>
|
|
244
|
+
</div>
|
|
245
|
+
<span class="ch-name">Discord</span><span class="tag">Webhook</span>
|
|
246
|
+
</div>
|
|
247
|
+
<span class="badge badge-idle" id="badge-discord">Not configured</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="card-body">
|
|
250
|
+
<div class="actions">
|
|
251
|
+
<label class="toggle-wrap">
|
|
252
|
+
<input type="checkbox" id="discord-enabled" onchange="toggleDiscordEnabled()">
|
|
253
|
+
<span class="toggle"></span>
|
|
254
|
+
</label>
|
|
255
|
+
<span class="toggle-lbl">Enable</span>
|
|
256
|
+
<button class="btn btn-sm btn-primary" id="save-discord-btn" onclick="saveDiscord()">Save</button>
|
|
257
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('discord')">Test</button>
|
|
258
|
+
</div>
|
|
259
|
+
<details class="guide">
|
|
260
|
+
<summary>Create a Discord webhook (1 min)</summary>
|
|
261
|
+
<ol>
|
|
262
|
+
<li>Open your Discord server → pick a channel → Edit Channel</li>
|
|
263
|
+
<li>Integrations → Webhooks → New Webhook → Copy URL</li>
|
|
264
|
+
</ol>
|
|
265
|
+
</details>
|
|
266
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="discord-webhook" placeholder="https://discord.com/api/webhooks/…" oninput="markDirty('discord')"></div>
|
|
267
|
+
<div class="fg"><label>Bot name (optional)</label><input type="text" id="discord-username" placeholder="Claude Notify" oninput="markDirty('discord')"></div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!-- ── Slack ─────────────────────────────────────────────────── -->
|
|
272
|
+
<div class="card" id="card-slack">
|
|
273
|
+
<div class="card-hd">
|
|
274
|
+
<div class="ch-meta">
|
|
275
|
+
<div class="channel-icon slack-icon">
|
|
276
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z"/><path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/><path d="M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z"/><path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z"/><path d="M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z"/><path d="M15.5 19H14v1.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z"/><path d="M10 9.5C10 8.67 9.33 8 8.5 8h-5C2.67 8 2 8.67 2 9.5S2.67 11 3.5 11h5c.83 0 1.5-.67 1.5-1.5z"/><path d="M8.5 5H10V3.5C10 2.67 9.33 2 8.5 2S7 2.67 7 3.5 7.67 5 8.5 5z"/></svg>
|
|
277
|
+
</div>
|
|
278
|
+
<span class="ch-name">Slack</span><span class="tag">Webhook</span>
|
|
279
|
+
</div>
|
|
280
|
+
<span class="badge badge-idle" id="badge-slack">Not configured</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="card-body">
|
|
283
|
+
<div class="actions">
|
|
284
|
+
<label class="toggle-wrap">
|
|
285
|
+
<input type="checkbox" id="slack-enabled" onchange="toggleSlackEnabled()">
|
|
286
|
+
<span class="toggle"></span>
|
|
287
|
+
</label>
|
|
288
|
+
<span class="toggle-lbl">Enable</span>
|
|
289
|
+
<button class="btn btn-sm btn-primary" id="save-slack-btn" onclick="saveSlack()">Save</button>
|
|
290
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('slack')">Test</button>
|
|
291
|
+
</div>
|
|
292
|
+
<details class="guide">
|
|
293
|
+
<summary>Create a Slack webhook (2 min)</summary>
|
|
294
|
+
<ol>
|
|
295
|
+
<li>Go to <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a> → Create New App → From scratch</li>
|
|
296
|
+
<li>Incoming Webhooks → On → Add New Webhook to Workspace → pick channel → Copy URL</li>
|
|
297
|
+
</ol>
|
|
298
|
+
</details>
|
|
299
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="slack-webhook" placeholder="https://hooks.slack.com/services/…" oninput="markDirty('slack')"></div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- ── Teams ─────────────────────────────────────────────────── -->
|
|
304
|
+
<div class="card" id="card-teams">
|
|
305
|
+
<div class="card-hd">
|
|
306
|
+
<div class="ch-meta">
|
|
307
|
+
<div class="channel-icon teams-icon">
|
|
308
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
309
|
+
</div>
|
|
310
|
+
<span class="ch-name">Teams</span><span class="tag">Webhook</span>
|
|
311
|
+
</div>
|
|
312
|
+
<span class="badge badge-idle" id="badge-teams">Not configured</span>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="card-body">
|
|
315
|
+
<div class="actions">
|
|
316
|
+
<label class="toggle-wrap">
|
|
317
|
+
<input type="checkbox" id="teams-enabled" onchange="toggleTeamsEnabled()">
|
|
318
|
+
<span class="toggle"></span>
|
|
319
|
+
</label>
|
|
320
|
+
<span class="toggle-lbl">Enable</span>
|
|
321
|
+
<button class="btn btn-sm btn-primary" id="save-teams-btn" onclick="saveTeams()">Save</button>
|
|
322
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('teams')">Test</button>
|
|
323
|
+
</div>
|
|
324
|
+
<details class="guide">
|
|
325
|
+
<summary>Create a Teams webhook (2 min)</summary>
|
|
326
|
+
<ol>
|
|
327
|
+
<li>In Teams, go to the channel → ••• → Workflows → "Post to a channel when a webhook request is received"</li>
|
|
328
|
+
<li>Follow the wizard → copy the webhook URL at the end</li>
|
|
329
|
+
</ol>
|
|
330
|
+
</details>
|
|
331
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="teams-webhook" placeholder="https://…webhook.office.com/…" oninput="markDirty('teams')"></div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
202
335
|
</div>
|
|
203
336
|
</section>
|
|
204
337
|
|
package/ui/public/style.css
CHANGED
|
@@ -231,6 +231,18 @@ main {
|
|
|
231
231
|
border-color: var(--accent);
|
|
232
232
|
color: #fff;
|
|
233
233
|
}
|
|
234
|
+
.pill { display: inline-flex; align-items: center; gap: 5px; }
|
|
235
|
+
.pill-dot {
|
|
236
|
+
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
|
237
|
+
}
|
|
238
|
+
.pill-dot-live { background: #4ade80; }
|
|
239
|
+
.pill-dot-idle { background: #facc15; }
|
|
240
|
+
.pill-dot-stale { background: #f87171; }
|
|
241
|
+
.pill-dismiss {
|
|
242
|
+
margin-left: 1px; font-size: 13px; line-height: 1;
|
|
243
|
+
opacity: .6; cursor: pointer;
|
|
244
|
+
}
|
|
245
|
+
.pill-dismiss:hover { opacity: 1; }
|
|
234
246
|
|
|
235
247
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
236
248
|
|
|
@@ -284,6 +296,10 @@ main {
|
|
|
284
296
|
.telegram-icon { background: #0d1e2e; color: #38bdf8; }
|
|
285
297
|
.whatsapp-icon { background: #0d2e1a; color: #4ade80; }
|
|
286
298
|
.sms-icon { background: #1e0d2e; color: #c084fc; }
|
|
299
|
+
.ntfy-icon { background: #1a2e0d; color: #86efac; }
|
|
300
|
+
.discord-icon { background: #0d0d2e; color: #a5b4fc; }
|
|
301
|
+
.slack-icon { background: #2e2a0d; color: #fbbf24; }
|
|
302
|
+
.teams-icon { background: #0d1e2e; color: #7dd3fc; }
|
|
287
303
|
|
|
288
304
|
/* ── Tags & badges ───────────────────────────────────────────────────────── */
|
|
289
305
|
|