omni-notify-mcp 1.2.3 → 1.3.1
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/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/ui/server.js +222 -2
- package/package.json +1 -1
- package/ui/public/app.js +107 -0
- package/ui/public/index.html +149 -6
- package/ui/public/style.css +20 -2
|
@@ -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/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();
|
|
@@ -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))
|
|
@@ -612,7 +702,14 @@ async function sendNotification(message, priority, client) {
|
|
|
612
702
|
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
613
703
|
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
614
704
|
let desktopOnlyMode = false;
|
|
615
|
-
|
|
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) {
|
|
616
713
|
const idleSecs = getOsIdleSeconds();
|
|
617
714
|
const threshold = cfg.idle?.thresholdSeconds ?? 120;
|
|
618
715
|
const userIsActive = idleSecs >= 0 && idleSecs < threshold;
|
|
@@ -711,6 +808,107 @@ async function sendNotification(message, priority, client) {
|
|
|
711
808
|
to: email.to, subject: "Claude Notify", text: message });
|
|
712
809
|
});
|
|
713
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
|
+
}
|
|
714
912
|
return [
|
|
715
913
|
results.length ? `Sent via: ${results.join(", ")}` : null,
|
|
716
914
|
errors.length ? `Errors: ${errors.join("; ")}` : null,
|
|
@@ -790,6 +988,28 @@ let lastUserMessageId;
|
|
|
790
988
|
// goes quiet.
|
|
791
989
|
let lastTelegramInboundAt = 0;
|
|
792
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
|
+
}
|
|
793
1013
|
// Session tagging: a session may declare a tag (e.g. "alphawave") when it
|
|
794
1014
|
// connects to /mcp?tag=alphawave. Telegram messages starting with "@<tag>"
|
|
795
1015
|
// are routed only to sessions with that exact tag (tag prefix stripped).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
let config = {};
|
|
4
4
|
const dirty = new Set();
|
|
5
5
|
|
|
6
|
+
// ── Card collapse/expand ──────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function toggleCard(id) {
|
|
9
|
+
const card = document.getElementById('card-' + id);
|
|
10
|
+
if (card) card.classList.toggle('expanded');
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
|
7
14
|
|
|
8
15
|
async function init() {
|
|
@@ -71,6 +78,29 @@ function populateForm() {
|
|
|
71
78
|
$("sms-from").value = sms.from ?? "";
|
|
72
79
|
$("sms-to").value = sms.to ?? "";
|
|
73
80
|
|
|
81
|
+
// ntfy
|
|
82
|
+
const ntfy = config.ntfy ?? {};
|
|
83
|
+
$("ntfy-enabled").checked = !!ntfy.enabled;
|
|
84
|
+
$("ntfy-topic").value = ntfy.topic ?? "";
|
|
85
|
+
$("ntfy-server").value = ntfy.serverUrl ?? "https://ntfy.sh";
|
|
86
|
+
$("ntfy-token").value = ntfy.token ?? "";
|
|
87
|
+
|
|
88
|
+
// Discord
|
|
89
|
+
const dc = config.discord ?? {};
|
|
90
|
+
$("discord-enabled").checked = !!dc.enabled;
|
|
91
|
+
$("discord-webhook").value = dc.webhookUrl ?? "";
|
|
92
|
+
$("discord-username").value = dc.username ?? "";
|
|
93
|
+
|
|
94
|
+
// Slack
|
|
95
|
+
const sl = config.slack ?? {};
|
|
96
|
+
$("slack-enabled").checked = !!sl.enabled;
|
|
97
|
+
$("slack-webhook").value = sl.webhookUrl ?? "";
|
|
98
|
+
|
|
99
|
+
// Teams
|
|
100
|
+
const tm = config.teams ?? {};
|
|
101
|
+
$("teams-enabled").checked = !!tm.enabled;
|
|
102
|
+
$("teams-webhook").value = tm.webhookUrl ?? "";
|
|
103
|
+
|
|
74
104
|
// DND
|
|
75
105
|
const dnd = config.dnd ?? {};
|
|
76
106
|
$("dnd-enabled").checked = !!dnd.enabled;
|
|
@@ -128,6 +158,22 @@ function updateBadges() {
|
|
|
128
158
|
sms.enabled && smsReady ? "ok" : smsReady ? "warn" : sms.accountSid ? "warn" : "idle",
|
|
129
159
|
sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
|
|
130
160
|
|
|
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");
|
|
164
|
+
|
|
165
|
+
const dcC = config.discord ?? {};
|
|
166
|
+
setBadge("discord", dcC.enabled && dcC.webhookUrl ? "ok" : dcC.webhookUrl ? "warn" : "idle",
|
|
167
|
+
dcC.enabled && dcC.webhookUrl ? "Configured" : dcC.webhookUrl ? "Disabled" : "Not configured");
|
|
168
|
+
|
|
169
|
+
const slC = config.slack ?? {};
|
|
170
|
+
setBadge("slack", slC.enabled && slC.webhookUrl ? "ok" : slC.webhookUrl ? "warn" : "idle",
|
|
171
|
+
slC.enabled && slC.webhookUrl ? "Configured" : slC.webhookUrl ? "Disabled" : "Not configured");
|
|
172
|
+
|
|
173
|
+
const tmC = config.teams ?? {};
|
|
174
|
+
setBadge("teams", tmC.enabled && tmC.webhookUrl ? "ok" : tmC.webhookUrl ? "warn" : "idle",
|
|
175
|
+
tmC.enabled && tmC.webhookUrl ? "Configured" : tmC.webhookUrl ? "Disabled" : "Not configured");
|
|
176
|
+
|
|
131
177
|
// DND badge: "Active" (red), "Scheduled" (warn), or "Off" (idle)
|
|
132
178
|
const dnd = config.dnd ?? {};
|
|
133
179
|
const sched = dnd.schedule ?? {};
|
|
@@ -219,6 +265,18 @@ async function toggleEmailEnabled() {
|
|
|
219
265
|
async function toggleSmsEnabled() {
|
|
220
266
|
await patch({ sms: { enabled: $("sms-enabled").checked } });
|
|
221
267
|
}
|
|
268
|
+
async function toggleNtfyEnabled() {
|
|
269
|
+
await patch({ ntfy: { enabled: $("ntfy-enabled").checked } });
|
|
270
|
+
}
|
|
271
|
+
async function toggleDiscordEnabled() {
|
|
272
|
+
await patch({ discord: { enabled: $("discord-enabled").checked } });
|
|
273
|
+
}
|
|
274
|
+
async function toggleSlackEnabled() {
|
|
275
|
+
await patch({ slack: { enabled: $("slack-enabled").checked } });
|
|
276
|
+
}
|
|
277
|
+
async function toggleTeamsEnabled() {
|
|
278
|
+
await patch({ teams: { enabled: $("teams-enabled").checked } });
|
|
279
|
+
}
|
|
222
280
|
|
|
223
281
|
async function saveTelegram() {
|
|
224
282
|
await patch({
|
|
@@ -290,6 +348,23 @@ async function saveSms() {
|
|
|
290
348
|
clearDirty("sms");
|
|
291
349
|
}
|
|
292
350
|
|
|
351
|
+
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() } });
|
|
353
|
+
clearDirty("ntfy");
|
|
354
|
+
}
|
|
355
|
+
async function saveDiscord() {
|
|
356
|
+
await patch({ discord: { enabled: $("discord-enabled").checked, webhookUrl: $("discord-webhook").value.trim(), username: $("discord-username").value.trim() || "Claude Notify" } });
|
|
357
|
+
clearDirty("discord");
|
|
358
|
+
}
|
|
359
|
+
async function saveSlack() {
|
|
360
|
+
await patch({ slack: { enabled: $("slack-enabled").checked, webhookUrl: $("slack-webhook").value.trim() } });
|
|
361
|
+
clearDirty("slack");
|
|
362
|
+
}
|
|
363
|
+
async function saveTeams() {
|
|
364
|
+
await patch({ teams: { enabled: $("teams-enabled").checked, webhookUrl: $("teams-webhook").value.trim() } });
|
|
365
|
+
clearDirty("teams");
|
|
366
|
+
}
|
|
367
|
+
|
|
293
368
|
async function patch(update) {
|
|
294
369
|
try {
|
|
295
370
|
const res = await fetch("/api/config", {
|
|
@@ -535,6 +610,38 @@ function toast(msg, type = "ok") {
|
|
|
535
610
|
|
|
536
611
|
function $(id) { return document.getElementById(id); }
|
|
537
612
|
|
|
613
|
+
// ── Page visibility reporting ─────────────────────────────────────────────
|
|
614
|
+
// Tell the server when this tab is focused so it can skip external channels
|
|
615
|
+
// while the user is actively watching the UI.
|
|
616
|
+
|
|
617
|
+
function reportVisibility(visible) {
|
|
618
|
+
fetch("/api/ui/visibility", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: { "Content-Type": "application/json" },
|
|
621
|
+
body: JSON.stringify({ visible }),
|
|
622
|
+
}).catch(() => {});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let visibilityHeartbeat;
|
|
626
|
+
function startVisibilityHeartbeat() {
|
|
627
|
+
clearInterval(visibilityHeartbeat);
|
|
628
|
+
visibilityHeartbeat = setInterval(() => {
|
|
629
|
+
if (!document.hidden) reportVisibility(true);
|
|
630
|
+
}, 15000);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
document.addEventListener("visibilitychange", () => {
|
|
634
|
+
reportVisibility(!document.hidden);
|
|
635
|
+
if (!document.hidden) startVisibilityHeartbeat();
|
|
636
|
+
else clearInterval(visibilityHeartbeat);
|
|
637
|
+
});
|
|
638
|
+
window.addEventListener("focus", () => { reportVisibility(true); startVisibilityHeartbeat(); });
|
|
639
|
+
window.addEventListener("blur", () => reportVisibility(false));
|
|
640
|
+
|
|
641
|
+
// Report on load and start heartbeat
|
|
642
|
+
reportVisibility(!document.hidden);
|
|
643
|
+
if (!document.hidden) startVisibilityHeartbeat();
|
|
644
|
+
|
|
538
645
|
function copyText(el) {
|
|
539
646
|
const text = el.textContent.replace(" 📋", "").trim();
|
|
540
647
|
navigator.clipboard.writeText(text).then(() => {
|
package/ui/public/index.html
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
<!-- ── Desktop ───────────────────────────────────────────────── -->
|
|
31
31
|
<div class="card" id="card-desktop">
|
|
32
|
-
<div class="card-hd">
|
|
32
|
+
<div class="card-hd" onclick="toggleCard('desktop')">
|
|
33
33
|
<div class="ch-meta">
|
|
34
34
|
<div class="channel-icon desktop-icon">
|
|
35
35
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
<span class="ch-name">Desktop</span>
|
|
38
38
|
</div>
|
|
39
39
|
<span class="badge badge-idle" id="badge-desktop">–</span>
|
|
40
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
40
41
|
</div>
|
|
41
42
|
<div class="card-body">
|
|
42
43
|
<div class="actions">
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
|
|
74
75
|
<!-- ── Email ─────────────────────────────────────────────────── -->
|
|
75
76
|
<div class="card" id="card-email">
|
|
76
|
-
<div class="card-hd">
|
|
77
|
+
<div class="card-hd" onclick="toggleCard('email')">
|
|
77
78
|
<div class="ch-meta">
|
|
78
79
|
<div class="channel-icon gmail-icon">
|
|
79
80
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
<span class="ch-name">Email</span><span class="tag">Gmail</span>
|
|
82
83
|
</div>
|
|
83
84
|
<span class="badge badge-idle" id="badge-email">Not configured</span>
|
|
85
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
84
86
|
</div>
|
|
85
87
|
<div class="card-body">
|
|
86
88
|
<!-- Connected -->
|
|
@@ -122,7 +124,7 @@
|
|
|
122
124
|
|
|
123
125
|
<!-- ── Telegram ──────────────────────────────────────────────── -->
|
|
124
126
|
<div class="card" id="card-telegram">
|
|
125
|
-
<div class="card-hd">
|
|
127
|
+
<div class="card-hd" onclick="toggleCard('telegram')">
|
|
126
128
|
<div class="ch-meta">
|
|
127
129
|
<div class="channel-icon telegram-icon">
|
|
128
130
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
@@ -130,6 +132,7 @@
|
|
|
130
132
|
<span class="ch-name">Telegram</span><span class="tag">Bot API</span>
|
|
131
133
|
</div>
|
|
132
134
|
<span class="badge badge-idle" id="badge-telegram">Not configured</span>
|
|
135
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
133
136
|
</div>
|
|
134
137
|
<div class="card-body">
|
|
135
138
|
<div class="actions">
|
|
@@ -163,7 +166,7 @@
|
|
|
163
166
|
|
|
164
167
|
<!-- ── SMS ───────────────────────────────────────────────────── -->
|
|
165
168
|
<div class="card" id="card-sms">
|
|
166
|
-
<div class="card-hd">
|
|
169
|
+
<div class="card-hd" onclick="toggleCard('sms')">
|
|
167
170
|
<div class="ch-meta">
|
|
168
171
|
<div class="channel-icon sms-icon">
|
|
169
172
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
|
@@ -171,6 +174,7 @@
|
|
|
171
174
|
<span class="ch-name">SMS</span><span class="tag">Twilio</span>
|
|
172
175
|
</div>
|
|
173
176
|
<span class="badge badge-idle" id="badge-sms">Not configured</span>
|
|
177
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
174
178
|
</div>
|
|
175
179
|
<div class="card-body">
|
|
176
180
|
<div class="actions">
|
|
@@ -199,6 +203,143 @@
|
|
|
199
203
|
</div>
|
|
200
204
|
</div>
|
|
201
205
|
|
|
206
|
+
<!-- ── ntfy ─────────────────────────────────────────────────── -->
|
|
207
|
+
<div class="card" id="card-ntfy">
|
|
208
|
+
<div class="card-hd" onclick="toggleCard('ntfy')">
|
|
209
|
+
<div class="ch-meta">
|
|
210
|
+
<div class="channel-icon ntfy-icon">
|
|
211
|
+
<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>
|
|
212
|
+
</div>
|
|
213
|
+
<span class="ch-name">ntfy</span><span class="tag">Push</span>
|
|
214
|
+
</div>
|
|
215
|
+
<span class="badge badge-idle" id="badge-ntfy">Not configured</span>
|
|
216
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="card-body">
|
|
219
|
+
<div class="actions">
|
|
220
|
+
<label class="toggle-wrap">
|
|
221
|
+
<input type="checkbox" id="ntfy-enabled" onchange="toggleNtfyEnabled()">
|
|
222
|
+
<span class="toggle"></span>
|
|
223
|
+
</label>
|
|
224
|
+
<span class="toggle-lbl">Enable</span>
|
|
225
|
+
<button class="btn btn-sm btn-primary" id="save-ntfy-btn" onclick="saveNtfy()">Save</button>
|
|
226
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('ntfy')">Test</button>
|
|
227
|
+
</div>
|
|
228
|
+
<details class="guide">
|
|
229
|
+
<summary>Set up ntfy (1 min, free, no account needed)</summary>
|
|
230
|
+
<ol>
|
|
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>
|
|
235
|
+
</ol>
|
|
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>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<!-- ── Discord ───────────────────────────────────────────────── -->
|
|
244
|
+
<div class="card" id="card-discord">
|
|
245
|
+
<div class="card-hd" onclick="toggleCard('discord')">
|
|
246
|
+
<div class="ch-meta">
|
|
247
|
+
<div class="channel-icon discord-icon">
|
|
248
|
+
<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>
|
|
249
|
+
</div>
|
|
250
|
+
<span class="ch-name">Discord</span><span class="tag">Webhook</span>
|
|
251
|
+
</div>
|
|
252
|
+
<span class="badge badge-idle" id="badge-discord">Not configured</span>
|
|
253
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
254
|
+
</div>
|
|
255
|
+
<div class="card-body">
|
|
256
|
+
<div class="actions">
|
|
257
|
+
<label class="toggle-wrap">
|
|
258
|
+
<input type="checkbox" id="discord-enabled" onchange="toggleDiscordEnabled()">
|
|
259
|
+
<span class="toggle"></span>
|
|
260
|
+
</label>
|
|
261
|
+
<span class="toggle-lbl">Enable</span>
|
|
262
|
+
<button class="btn btn-sm btn-primary" id="save-discord-btn" onclick="saveDiscord()">Save</button>
|
|
263
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('discord')">Test</button>
|
|
264
|
+
</div>
|
|
265
|
+
<details class="guide">
|
|
266
|
+
<summary>Create a Discord webhook (1 min)</summary>
|
|
267
|
+
<ol>
|
|
268
|
+
<li>Open your Discord server → pick a channel → Edit Channel</li>
|
|
269
|
+
<li>Integrations → Webhooks → New Webhook → Copy URL</li>
|
|
270
|
+
</ol>
|
|
271
|
+
</details>
|
|
272
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="discord-webhook" placeholder="https://discord.com/api/webhooks/…" oninput="markDirty('discord')"></div>
|
|
273
|
+
<div class="fg"><label>Bot name (optional)</label><input type="text" id="discord-username" placeholder="Claude Notify" oninput="markDirty('discord')"></div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<!-- ── Slack ─────────────────────────────────────────────────── -->
|
|
278
|
+
<div class="card" id="card-slack">
|
|
279
|
+
<div class="card-hd" onclick="toggleCard('slack')">
|
|
280
|
+
<div class="ch-meta">
|
|
281
|
+
<div class="channel-icon slack-icon">
|
|
282
|
+
<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>
|
|
283
|
+
</div>
|
|
284
|
+
<span class="ch-name">Slack</span><span class="tag">Webhook</span>
|
|
285
|
+
</div>
|
|
286
|
+
<span class="badge badge-idle" id="badge-slack">Not configured</span>
|
|
287
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="card-body">
|
|
290
|
+
<div class="actions">
|
|
291
|
+
<label class="toggle-wrap">
|
|
292
|
+
<input type="checkbox" id="slack-enabled" onchange="toggleSlackEnabled()">
|
|
293
|
+
<span class="toggle"></span>
|
|
294
|
+
</label>
|
|
295
|
+
<span class="toggle-lbl">Enable</span>
|
|
296
|
+
<button class="btn btn-sm btn-primary" id="save-slack-btn" onclick="saveSlack()">Save</button>
|
|
297
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('slack')">Test</button>
|
|
298
|
+
</div>
|
|
299
|
+
<details class="guide">
|
|
300
|
+
<summary>Create a Slack webhook (2 min)</summary>
|
|
301
|
+
<ol>
|
|
302
|
+
<li>Go to <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a> → Create New App → From scratch</li>
|
|
303
|
+
<li>Incoming Webhooks → On → Add New Webhook to Workspace → pick channel → Copy URL</li>
|
|
304
|
+
</ol>
|
|
305
|
+
</details>
|
|
306
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="slack-webhook" placeholder="https://hooks.slack.com/services/…" oninput="markDirty('slack')"></div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<!-- ── Teams ─────────────────────────────────────────────────── -->
|
|
311
|
+
<div class="card" id="card-teams">
|
|
312
|
+
<div class="card-hd" onclick="toggleCard('teams')">
|
|
313
|
+
<div class="ch-meta">
|
|
314
|
+
<div class="channel-icon teams-icon">
|
|
315
|
+
<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>
|
|
316
|
+
</div>
|
|
317
|
+
<span class="ch-name">Teams</span><span class="tag">Webhook</span>
|
|
318
|
+
</div>
|
|
319
|
+
<span class="badge badge-idle" id="badge-teams">Not configured</span>
|
|
320
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="card-body">
|
|
323
|
+
<div class="actions">
|
|
324
|
+
<label class="toggle-wrap">
|
|
325
|
+
<input type="checkbox" id="teams-enabled" onchange="toggleTeamsEnabled()">
|
|
326
|
+
<span class="toggle"></span>
|
|
327
|
+
</label>
|
|
328
|
+
<span class="toggle-lbl">Enable</span>
|
|
329
|
+
<button class="btn btn-sm btn-primary" id="save-teams-btn" onclick="saveTeams()">Save</button>
|
|
330
|
+
<button class="btn btn-sm btn-ghost" onclick="testChannel('teams')">Test</button>
|
|
331
|
+
</div>
|
|
332
|
+
<details class="guide">
|
|
333
|
+
<summary>Create a Teams webhook (2 min)</summary>
|
|
334
|
+
<ol>
|
|
335
|
+
<li>In Teams, go to the channel → ••• → Workflows → "Post to a channel when a webhook request is received"</li>
|
|
336
|
+
<li>Follow the wizard → copy the webhook URL at the end</li>
|
|
337
|
+
</ol>
|
|
338
|
+
</details>
|
|
339
|
+
<div class="fg"><label>Webhook URL</label><input type="password" id="teams-webhook" placeholder="https://…webhook.office.com/…" oninput="markDirty('teams')"></div>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
202
343
|
</div>
|
|
203
344
|
</section>
|
|
204
345
|
|
|
@@ -211,7 +352,7 @@
|
|
|
211
352
|
|
|
212
353
|
<!-- ── Do Not Disturb ────────────────────────────────────────── -->
|
|
213
354
|
<div class="card" id="card-dnd">
|
|
214
|
-
<div class="card-hd">
|
|
355
|
+
<div class="card-hd" onclick="toggleCard('dnd')">
|
|
215
356
|
<div class="ch-meta">
|
|
216
357
|
<div class="channel-icon" style="background:#3b2f12;color:#f59e0b">
|
|
217
358
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93l14.14 14.14"/></svg>
|
|
@@ -219,6 +360,7 @@
|
|
|
219
360
|
<span class="ch-name">Do Not Disturb</span>
|
|
220
361
|
</div>
|
|
221
362
|
<span class="badge badge-idle" id="badge-dnd">–</span>
|
|
363
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
222
364
|
</div>
|
|
223
365
|
<div class="card-body">
|
|
224
366
|
<div class="actions">
|
|
@@ -258,7 +400,7 @@
|
|
|
258
400
|
|
|
259
401
|
<!-- ── Idle gating ───────────────────────────────────────────── -->
|
|
260
402
|
<div class="card" id="card-idle">
|
|
261
|
-
<div class="card-hd">
|
|
403
|
+
<div class="card-hd" onclick="toggleCard('idle')">
|
|
262
404
|
<div class="ch-meta">
|
|
263
405
|
<div class="channel-icon" style="background:#12293b;color:#3b82f6">
|
|
264
406
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
@@ -266,6 +408,7 @@
|
|
|
266
408
|
<span class="ch-name">Idle gating</span>
|
|
267
409
|
</div>
|
|
268
410
|
<span class="badge badge-idle" id="badge-idle">–</span>
|
|
411
|
+
<svg class="card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
269
412
|
</div>
|
|
270
413
|
<div class="card-body">
|
|
271
414
|
<div class="actions">
|
package/ui/public/style.css
CHANGED
|
@@ -264,10 +264,14 @@ main {
|
|
|
264
264
|
align-items: center;
|
|
265
265
|
justify-content: space-between;
|
|
266
266
|
padding: 10px 14px;
|
|
267
|
-
border-bottom: 2px solid #4a4a5a;
|
|
268
267
|
gap: 8px;
|
|
269
268
|
flex-shrink: 0;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
user-select: none;
|
|
271
|
+
transition: background .1s;
|
|
270
272
|
}
|
|
273
|
+
.card-hd:hover { background: rgba(255,255,255,0.03); }
|
|
274
|
+
.card.expanded .card-hd { border-bottom: 2px solid #4a4a5a; }
|
|
271
275
|
.ch-meta {
|
|
272
276
|
display: flex;
|
|
273
277
|
align-items: center;
|
|
@@ -275,13 +279,23 @@ main {
|
|
|
275
279
|
}
|
|
276
280
|
.ch-name { font-size: 14px; font-weight: 600; }
|
|
277
281
|
|
|
282
|
+
.card-chevron {
|
|
283
|
+
width: 14px;
|
|
284
|
+
height: 14px;
|
|
285
|
+
flex-shrink: 0;
|
|
286
|
+
color: var(--text-3);
|
|
287
|
+
transition: transform .2s;
|
|
288
|
+
}
|
|
289
|
+
.card.expanded .card-chevron { transform: rotate(180deg); }
|
|
290
|
+
|
|
278
291
|
.card-body {
|
|
279
292
|
padding: 12px 14px;
|
|
280
|
-
display:
|
|
293
|
+
display: none;
|
|
281
294
|
flex-direction: column;
|
|
282
295
|
gap: 10px;
|
|
283
296
|
flex: 1;
|
|
284
297
|
}
|
|
298
|
+
.card.expanded .card-body { display: flex; }
|
|
285
299
|
|
|
286
300
|
/* ── Channel icons ───────────────────────────────────────────────────────── */
|
|
287
301
|
|
|
@@ -296,6 +310,10 @@ main {
|
|
|
296
310
|
.telegram-icon { background: #0d1e2e; color: #38bdf8; }
|
|
297
311
|
.whatsapp-icon { background: #0d2e1a; color: #4ade80; }
|
|
298
312
|
.sms-icon { background: #1e0d2e; color: #c084fc; }
|
|
313
|
+
.ntfy-icon { background: #1a2e0d; color: #86efac; }
|
|
314
|
+
.discord-icon { background: #0d0d2e; color: #a5b4fc; }
|
|
315
|
+
.slack-icon { background: #2e2a0d; color: #fbbf24; }
|
|
316
|
+
.teams-icon { background: #0d1e2e; color: #7dd3fc; }
|
|
299
317
|
|
|
300
318
|
/* ── Tags & badges ───────────────────────────────────────────────────────── */
|
|
301
319
|
|