omni-notify-mcp 1.0.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/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "omni-notify-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Multi-channel notification MCP server: desktop, Telegram, WhatsApp, SMS, and email — configurable via a web UI",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "omni-notify-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "ui/public/",
13
+ "config.example.json",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "notifications",
20
+ "telegram",
21
+ "whatsapp",
22
+ "sms",
23
+ "email",
24
+ "desktop",
25
+ "claude",
26
+ "ai-tools"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/menih/omni-notify-mcp"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc && tsc -p ui/tsconfig.json",
38
+ "build:mcp": "tsc",
39
+ "build:ui": "tsc -p ui/tsconfig.json",
40
+ "start": "node dist/index.js",
41
+ "ui": "npm run build:ui && node dist/ui/server.js",
42
+ "prepublishOnly": "npm run build:mcp"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.0.0",
46
+ "express": "^4.19.2",
47
+ "googleapis": "^144.0.0",
48
+ "node-notifier": "^10.0.1",
49
+ "nodemailer": "^6.9.9",
50
+ "open": "^10.1.0",
51
+ "twilio": "^5.3.0",
52
+ "zod": "^3.22.4"
53
+ },
54
+ "devDependencies": {
55
+ "@types/express": "^4.17.21",
56
+ "@types/node": "^20.0.0",
57
+ "@types/node-notifier": "^8.0.5",
58
+ "@types/nodemailer": "^6.4.14",
59
+ "ts-node": "^10.9.2",
60
+ "typescript": "^5.4.0"
61
+ }
62
+ }
@@ -0,0 +1,467 @@
1
+ // ── State ─────────────────────────────────────────────────────────────────
2
+
3
+ let config = {};
4
+ const dirty = new Set();
5
+
6
+ // ── Bootstrap ─────────────────────────────────────────────────────────────
7
+
8
+ async function init() {
9
+ handleUrlParams();
10
+ await loadConfig();
11
+ renderOsHint();
12
+ }
13
+
14
+ function handleUrlParams() {
15
+ const params = new URLSearchParams(location.search);
16
+ if (params.has("success")) {
17
+ const msg = params.get("success") === "gmail_connected"
18
+ ? "Gmail connected successfully!"
19
+ : "Success!";
20
+ toast(msg, "ok");
21
+ }
22
+ if (params.has("error")) {
23
+ toast("Error: " + decodeURIComponent(params.get("error")), "error");
24
+ }
25
+ if (params.toString()) {
26
+ history.replaceState({}, "", location.pathname);
27
+ }
28
+ }
29
+
30
+ async function loadConfig() {
31
+ try {
32
+ const res = await fetch("/api/config");
33
+ config = await res.json();
34
+ populateForm();
35
+ updateBadges();
36
+ } catch (e) {
37
+ toast("Failed to load config: " + e, "error");
38
+ }
39
+ }
40
+
41
+ // ── Populate form from config ─────────────────────────────────────────────
42
+
43
+ function populateForm() {
44
+ // Desktop
45
+ $("desktop-enabled").checked = !!config.desktop?.enabled;
46
+
47
+ // Email / Gmail
48
+ const email = config.email ?? {};
49
+ if (email.connectedEmail) {
50
+ showGmailConnected(email.connectedEmail, email.to);
51
+ } else {
52
+ showGmailSetup(email);
53
+ }
54
+
55
+ // Telegram
56
+ const tg = config.telegram ?? {};
57
+ $("telegram-enabled").checked = !!tg.enabled;
58
+ $("telegram-token").value = tg.token ?? "";
59
+ $("telegram-chatid").value = tg.chatId ?? "";
60
+
61
+
62
+ // SMS
63
+ const sms = config.sms ?? {};
64
+ $("sms-enabled").checked = !!sms.enabled;
65
+ $("sms-sid").value = sms.accountSid ?? "";
66
+ $("sms-token").value = sms.authToken ?? "";
67
+ $("sms-from").value = sms.from ?? "";
68
+ $("sms-to").value = sms.to ?? "";
69
+ }
70
+
71
+ function showGmailConnected(email, to) {
72
+ $("gmail-connected-state").classList.remove("hidden");
73
+ $("gmail-setup-state").classList.add("hidden");
74
+ $("gmail-connected-email").textContent = email;
75
+ $("gmail-to-connected").value = to ?? email;
76
+ $("email-enabled").checked = !!config.email?.enabled;
77
+ }
78
+
79
+ function showGmailSetup(email) {
80
+ $("gmail-connected-state").classList.add("hidden");
81
+ $("gmail-setup-state").classList.remove("hidden");
82
+ $("gmail-address").value = email.user ?? email.connectedEmail ?? "";
83
+ $("gmail-guide").removeAttribute("open");
84
+ }
85
+
86
+ // ── Badges ────────────────────────────────────────────────────────────────
87
+
88
+ function updateBadges() {
89
+ setBadge("desktop", config.desktop?.enabled ? "ok" : "idle",
90
+ config.desktop?.enabled ? "Enabled" : "Disabled");
91
+
92
+ const email = config.email ?? {};
93
+ setBadge("email",
94
+ email.connectedEmail ? "ok" : email.clientId ? "warn" : "idle",
95
+ email.connectedEmail ? "Connected" : email.clientId ? "Credentials saved" : "Not configured");
96
+
97
+ const tg = config.telegram ?? {};
98
+ setBadge("telegram",
99
+ tg.enabled && tg.token && tg.chatId ? "ok" : tg.token ? "warn" : "idle",
100
+ tg.enabled && tg.token && tg.chatId ? "Configured" : tg.token ? "Incomplete" : "Not configured");
101
+
102
+ const sms = config.sms ?? {};
103
+ setBadge("sms",
104
+ sms.enabled && sms.accountSid && sms.authToken ? "ok" : sms.accountSid ? "warn" : "idle",
105
+ sms.enabled && sms.accountSid && sms.authToken ? "Configured" : sms.accountSid ? "Incomplete" : "Not configured");
106
+ }
107
+
108
+ function setBadge(channel, type, text) {
109
+ const el = $("badge-" + channel);
110
+ el.className = "badge badge-" + type;
111
+ el.textContent = text;
112
+ }
113
+
114
+ // ── Save handlers ─────────────────────────────────────────────────────────
115
+
116
+ function saveDesktop() {
117
+ patch({ desktop: { enabled: $("desktop-enabled").checked } });
118
+ }
119
+
120
+ async function saveEmail() {
121
+ const to = $("gmail-to-connected").value.trim();
122
+ const enabled = $("email-enabled").checked;
123
+ await patch({ email: { to, enabled } });
124
+ clearDirty("email");
125
+ }
126
+
127
+ async function saveTelegram() {
128
+ await patch({
129
+ telegram: {
130
+ enabled: $("telegram-enabled").checked,
131
+ token: $("telegram-token").value.trim(),
132
+ chatId: $("telegram-chatid").value.trim(),
133
+ },
134
+ });
135
+ clearDirty("telegram");
136
+ }
137
+
138
+ async function detectChatId() {
139
+ const token = $("telegram-token").value.trim();
140
+ if (!token) { toast("Enter bot token first.", "error"); return; }
141
+ try {
142
+ const res = await fetch(`/api/telegram/chatid?token=${encodeURIComponent(token)}`);
143
+ const json = await res.json();
144
+ if (!res.ok) throw new Error(json.error);
145
+ $("telegram-chatid").value = json.chatId;
146
+ markDirty("telegram");
147
+ toast("Chat ID detected: " + json.chatId, "ok");
148
+ } catch (e) {
149
+ toast("" + e, "error");
150
+ }
151
+ }
152
+
153
+ async function saveSms() {
154
+ await patch({
155
+ sms: {
156
+ enabled: $("sms-enabled").checked,
157
+ accountSid: $("sms-sid").value.trim(),
158
+ authToken: $("sms-token").value.trim(),
159
+ from: $("sms-from").value.trim(),
160
+ to: $("sms-to").value.trim(),
161
+ },
162
+ });
163
+ clearDirty("sms");
164
+ }
165
+
166
+ async function patch(update) {
167
+ try {
168
+ const res = await fetch("/api/config", {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json" },
171
+ body: JSON.stringify(update),
172
+ });
173
+ const json = await res.json();
174
+ if (!res.ok) throw new Error(json.error);
175
+ toast("Saved", "ok");
176
+ await loadConfig();
177
+ } catch (e) {
178
+ toast("Save failed: " + e, "error");
179
+ }
180
+ }
181
+
182
+ // ── gcloud auth ───────────────────────────────────────────────────────────
183
+
184
+ async function checkGcloud() {
185
+ const res = await fetch("/api/gcloud/status");
186
+ const status = await res.json();
187
+ renderGcloudPanel(status);
188
+ return status;
189
+ }
190
+
191
+ function renderGcloudPanel(status) {
192
+ const panel = $("gcloud-auth-panel");
193
+ const row = $("gcloud-status-row");
194
+ panel.classList.remove("hidden");
195
+
196
+ if (!status.installed) {
197
+ row.innerHTML = `
198
+ <span class="dot dot-warn"></span>
199
+ <span class="status-text">gcloud not installed —
200
+ <code class="cp" onclick="copyText(this)" style="font-size:11px">brew install --cask google-cloud-sdk</code>
201
+ then refresh
202
+ </span>`;
203
+ return;
204
+ }
205
+
206
+ if (status.authenticated) {
207
+ row.innerHTML = `
208
+ <span class="dot dot-ok"></span>
209
+ <span class="status-text">gcloud logged in as</span>
210
+ <span class="status-account">${status.account}</span>`;
211
+ return;
212
+ }
213
+
214
+ row.innerHTML = `
215
+ <span class="dot dot-warn"></span>
216
+ <span class="status-text">gcloud not logged in</span>
217
+ <button class="btn btn-secondary" style="margin-left:auto;padding:4px 12px;font-size:12px"
218
+ onclick="gcloudLogin()">Login with Google</button>`;
219
+ }
220
+
221
+ async function gcloudLogin() {
222
+ const row = $("gcloud-status-row");
223
+ const logPanel = $("gcloud-log");
224
+
225
+ row.innerHTML = `<span class="dot dot-spin"></span>
226
+ <span class="status-text">Opening browser for Google login…</span>`;
227
+ logPanel.classList.remove("hidden");
228
+ logPanel.textContent = "";
229
+
230
+ const es = new EventSource("/api/gcloud/login");
231
+
232
+ es.onmessage = (e) => {
233
+ const { type, msg } = JSON.parse(e.data);
234
+
235
+ if (type === "already_authed") {
236
+ renderGcloudPanel({ installed: true, authenticated: true, account: msg });
237
+ logPanel.classList.add("hidden");
238
+ es.close();
239
+ return;
240
+ }
241
+
242
+ if (type === "done") {
243
+ renderGcloudPanel({ installed: true, authenticated: true, account: msg });
244
+ logPanel.classList.add("hidden");
245
+ toast("Logged in as " + msg, "ok");
246
+ es.close();
247
+ return;
248
+ }
249
+
250
+ if (type === "error") {
251
+ row.innerHTML = `<span class="dot dot-warn"></span>
252
+ <span class="status-text" style="color:var(--danger)">${msg}</span>`;
253
+ es.close();
254
+ return;
255
+ }
256
+
257
+ if (type === "open_browser") {
258
+ logPanel.textContent += "Browser opened for login. Complete auth there, then come back here.\n";
259
+ return;
260
+ }
261
+
262
+ if (type === "log") {
263
+ logPanel.textContent += msg + "\n";
264
+ logPanel.scrollTop = logPanel.scrollHeight;
265
+ }
266
+ };
267
+
268
+ es.onerror = () => {
269
+ row.innerHTML = `<span class="dot dot-warn"></span>
270
+ <span class="status-text" style="color:var(--danger)">Connection lost</span>`;
271
+ es.close();
272
+ };
273
+ }
274
+
275
+ // ── Gmail App Password setup ──────────────────────────────────────────────
276
+
277
+ function openAppPasswords() {
278
+ fetch("/api/google/open-apppasswords").catch(() => {});
279
+ }
280
+
281
+ async function saveAppPassword() {
282
+ const gmailAddress = $("gmail-address").value.trim();
283
+ const appPassword = $("gmail-app-password").value.replace(/\s/g, "");
284
+ if (!gmailAddress || !appPassword) {
285
+ toast("Enter your Gmail address and app password.", "error");
286
+ return;
287
+ }
288
+ const btn = document.querySelector("#gmail-setup-state .btn-primary");
289
+ if (btn) { btn.disabled = true; btn.textContent = "Connecting…"; }
290
+ try {
291
+ const res = await fetch("/api/google/apppassword", {
292
+ method: "POST",
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify({ gmailAddress, appPassword }),
295
+ });
296
+ const json = await res.json();
297
+ if (!res.ok) throw new Error(json.error);
298
+ toast("Gmail connected!", "ok");
299
+ await loadConfig();
300
+ } catch (e) {
301
+ toast("Failed: " + e, "error");
302
+ if (btn) { btn.disabled = false; btn.textContent = "Connect"; }
303
+ }
304
+ }
305
+
306
+ async function disconnectGmail() {
307
+ if (!confirm("Disconnect Gmail? You'll need to re-authenticate to send emails.")) return;
308
+ try {
309
+ const res = await fetch("/auth/google", { method: "DELETE" });
310
+ if (!res.ok) throw new Error((await res.json()).error);
311
+ toast("Gmail disconnected", "ok");
312
+ await loadConfig();
313
+ } catch (e) {
314
+ toast("Error: " + e, "error");
315
+ }
316
+ }
317
+
318
+ // ── Test channels ─────────────────────────────────────────────────────────
319
+
320
+ async function testChannel(channel) {
321
+ const btn = document.querySelector(`#card-${channel === "email" ? "email" : channel} .btn-secondary`);
322
+ if (btn) { btn.disabled = true; btn.textContent = "Sending…"; }
323
+
324
+ try {
325
+ const res = await fetch(`/api/test/${channel}`, { method: "POST" });
326
+ const json = await res.json();
327
+ if (!res.ok) throw new Error(json.error);
328
+ toast(json.message, "ok");
329
+ setBadge(channel, "ok", "✓ Works");
330
+ } catch (e) {
331
+ toast("Test failed: " + e, "error");
332
+ setBadge(channel, "error", "Failed");
333
+ } finally {
334
+ if (btn) {
335
+ btn.disabled = false;
336
+ btn.textContent = "Send test";
337
+ }
338
+ }
339
+ }
340
+
341
+ // ── OS hint ───────────────────────────────────────────────────────────────
342
+
343
+ function renderOsHint() {
344
+ const ua = navigator.userAgent;
345
+ const hint = $("os-hint");
346
+ if (ua.includes("Mac")) {
347
+ hint.textContent = "macOS: allow notifications for Terminal in System Settings";
348
+ hint.classList.add("visible");
349
+ } else if (ua.includes("Linux")) {
350
+ hint.textContent = "Linux: needs libnotify (sudo apt install libnotify-bin)";
351
+ hint.classList.add("visible");
352
+ }
353
+ }
354
+
355
+ // ── Dirty tracking ────────────────────────────────────────────────────────
356
+
357
+ function markDirty(section) {
358
+ dirty.add(section);
359
+ const btn = $("save-" + section + "-btn");
360
+ if (btn) btn.classList.add("dirty");
361
+ }
362
+
363
+ function clearDirty(section) {
364
+ dirty.delete(section);
365
+ const btn = $("save-" + section + "-btn");
366
+ if (btn) btn.classList.remove("dirty");
367
+ }
368
+
369
+ // ── Toast ─────────────────────────────────────────────────────────────────
370
+
371
+ let toastTimer;
372
+ function toast(msg, type = "ok") {
373
+ const el = $("toast");
374
+ el.textContent = msg;
375
+ el.className = "toast toast-" + type;
376
+ clearTimeout(toastTimer);
377
+ toastTimer = setTimeout(() => el.classList.add("hidden"), 3500);
378
+ }
379
+
380
+ // ── Utils ─────────────────────────────────────────────────────────────────
381
+
382
+ function $(id) { return document.getElementById(id); }
383
+
384
+ function copyText(el) {
385
+ const text = el.textContent.replace(" 📋", "").trim();
386
+ navigator.clipboard.writeText(text).then(() => {
387
+ const orig = el.textContent;
388
+ el.textContent = "Copied!";
389
+ setTimeout(() => (el.textContent = orig), 1500);
390
+ });
391
+ }
392
+
393
+ // ── Log panel ─────────────────────────────────────────────────────────────
394
+
395
+ // Each unique client gets a stable color
396
+ const clientColors = ["#7c6dfa","#38bdf8","#f472b6","#fb923c","#a3e635","#e879f9","#34d399","#facc15"];
397
+ const clientColorMap = {};
398
+ let clientColorIndex = 0;
399
+
400
+ function clientColor(id) {
401
+ if (!clientColorMap[id]) {
402
+ clientColorMap[id] = clientColors[clientColorIndex % clientColors.length];
403
+ clientColorIndex++;
404
+ }
405
+ return clientColorMap[id];
406
+ }
407
+
408
+ function parseLogEntry(raw) {
409
+ // Format: [ISO_TS][opt: [client]] DIR [channel] message
410
+ const m = raw.match(/^\[([^\]]+)\](?:\s\[([^\]]+)\])?\s([→←·])\s\[([^\]]+)\]\s(.*)$/s);
411
+ if (!m) return null;
412
+ return { ts: m[1], client: m[2] || null, dir: m[3], channel: m[4], msg: m[5] };
413
+ }
414
+
415
+ function renderLogEntry(raw) {
416
+ const panel = $("log-panel");
417
+ const p = parseLogEntry(raw);
418
+ const el = document.createElement("div");
419
+ el.className = "log-entry";
420
+
421
+ if (p) {
422
+ const ts = new Date(p.ts).toLocaleTimeString([], { hour12: false });
423
+ const dirClass = p.dir === "→" ? "log-dir-out" : p.dir === "←" ? "log-dir-in" : "log-dir-info";
424
+ const clientHtml = p.client
425
+ ? `<span class="log-client" style="color:${clientColor(p.client)}">${p.client}</span>`
426
+ : "";
427
+ el.innerHTML = `
428
+ <span class="log-ts">${ts}</span>
429
+ ${clientHtml}
430
+ <span class="${dirClass}">${p.dir}</span>
431
+ <span class="log-channel">[${p.channel}]</span>
432
+ <span class="log-msg">${p.msg.replace(/</g,"&lt;")}</span>`;
433
+ } else {
434
+ el.innerHTML = `<span class="log-msg">${raw.replace(/</g,"&lt;")}</span>`;
435
+ }
436
+
437
+ const atBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 20;
438
+ panel.appendChild(el);
439
+ if (atBottom) panel.scrollTop = panel.scrollHeight;
440
+ }
441
+
442
+ function clearLog() {
443
+ $("log-panel").innerHTML = "";
444
+ }
445
+
446
+ function connectLogStream() {
447
+ const dot = $("log-dot");
448
+ const es = new EventSource("/api/logs");
449
+
450
+ es.onmessage = (e) => {
451
+ renderLogEntry(JSON.parse(e.data));
452
+ };
453
+
454
+ es.onopen = () => {
455
+ if (dot) { dot.className = "dot dot-ok"; dot.style.display = "inline-block"; }
456
+ };
457
+
458
+ es.onerror = () => {
459
+ if (dot) { dot.className = "dot dot-warn"; dot.style.display = "inline-block"; }
460
+ es.close();
461
+ setTimeout(connectLogStream, 3000);
462
+ };
463
+ }
464
+
465
+ // ── Init ──────────────────────────────────────────────────────────────────
466
+
467
+ document.addEventListener("DOMContentLoaded", () => { init(); connectLogStream(); });