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.
@@ -0,0 +1,708 @@
1
+ import { randomUUID } from "crypto";
2
+ import express from "express";
3
+ import { google } from "googleapis";
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
5
+ import { homedir, networkInterfaces } from "os";
6
+ import { join } from "path";
7
+ import { fileURLToPath } from "url";
8
+ import { spawnSync, spawn } from "child_process";
9
+ import open from "open";
10
+ import notifier from "node-notifier";
11
+ import nodemailer from "nodemailer";
12
+ import twilio from "twilio";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { z } from "zod";
16
+ const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
17
+ const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
18
+ const PUBLIC_DIR = join(fileURLToPath(new URL("../../ui/public", import.meta.url)));
19
+ const CONFIG_DIR = join(homedir(), ".notify-mcp");
20
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
21
+ const ADC_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
22
+ function defaultConfig() {
23
+ return {
24
+ desktop: { enabled: false },
25
+ telegram: { enabled: false, token: "", chatId: "" },
26
+ whatsapp: { enabled: false, instanceId: "", apiToken: "", phone: "" },
27
+ sms: { enabled: false, accountSid: "", authToken: "", from: "", to: "" },
28
+ email: { enabled: false, to: "" },
29
+ };
30
+ }
31
+ function loadConfig() {
32
+ if (!existsSync(CONFIG_PATH))
33
+ return defaultConfig();
34
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
35
+ }
36
+ function saveConfig(config) {
37
+ if (!existsSync(CONFIG_DIR))
38
+ mkdirSync(CONFIG_DIR, { recursive: true });
39
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
40
+ }
41
+ const MASKED = "••••••••";
42
+ function maskSecrets(config) {
43
+ const c = JSON.parse(JSON.stringify(config));
44
+ if (c.email?.pass)
45
+ c.email.pass = MASKED;
46
+ if (c.email?.clientSecret)
47
+ c.email.clientSecret = MASKED;
48
+ if (c.email?.refreshToken)
49
+ c.email.refreshToken = MASKED;
50
+ if (c.email?.accessToken)
51
+ c.email.accessToken = MASKED;
52
+ if (c.sms?.authToken)
53
+ c.sms.authToken = MASKED;
54
+ if (c.telegram?.token)
55
+ c.telegram.token = MASKED;
56
+ if (c.whatsapp?.apiToken)
57
+ c.whatsapp.apiToken = MASKED;
58
+ return c;
59
+ }
60
+ function mergePreservingSecrets(existing, update) {
61
+ const merged = { ...defaultConfig(), ...existing };
62
+ for (const section of ["desktop", "telegram", "whatsapp", "sms", "email"]) {
63
+ merged[section] = { ...(merged[section] || {}), ...(update[section] || {}) };
64
+ }
65
+ const guard = (path) => {
66
+ const [sec, field] = path;
67
+ if (update[sec]?.[field] === MASKED) {
68
+ merged[sec][field] = existing[sec]?.[field] ?? "";
69
+ }
70
+ };
71
+ guard(["email", "pass"]);
72
+ guard(["email", "clientSecret"]);
73
+ guard(["email", "refreshToken"]);
74
+ guard(["email", "accessToken"]);
75
+ guard(["sms", "authToken"]);
76
+ guard(["telegram", "token"]);
77
+ guard(["whatsapp", "apiToken"]);
78
+ return merged;
79
+ }
80
+ const app = express();
81
+ app.use(express.json());
82
+ app.use(express.static(PUBLIC_DIR));
83
+ // ── Config API ────────────────────────────────────────────────────────────────
84
+ app.get("/api/config", (_req, res) => {
85
+ res.json(maskSecrets(loadConfig()));
86
+ });
87
+ app.post("/api/config", (req, res) => {
88
+ try {
89
+ const merged = mergePreservingSecrets(loadConfig(), req.body);
90
+ saveConfig(merged);
91
+ res.json({ ok: true });
92
+ }
93
+ catch (err) {
94
+ res.status(500).json({ error: String(err) });
95
+ }
96
+ });
97
+ // ── Test routes ───────────────────────────────────────────────────────────────
98
+ app.post("/api/test/desktop", (_req, res) => {
99
+ const time = new Date().toLocaleTimeString();
100
+ notifier.notify({ title: "Claude Notify", message: `Desktop is working! (${time})`, sound: true }, (err) => {
101
+ if (err)
102
+ res.status(500).json({ error: String(err) });
103
+ else
104
+ res.json({ ok: true, message: "Desktop notification sent!" });
105
+ });
106
+ });
107
+ app.post("/api/test/telegram", async (_req, res) => {
108
+ const config = loadConfig();
109
+ const { token, chatId } = config.telegram ?? {};
110
+ if (!token || !chatId) {
111
+ res.status(400).json({ error: "Token and Chat ID are required." });
112
+ return;
113
+ }
114
+ try {
115
+ const r = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ chat_id: chatId, text: "Test from Claude Notify — Telegram is working!" }),
119
+ });
120
+ if (!r.ok)
121
+ throw new Error(`Telegram ${r.status}: ${await r.text()}`);
122
+ res.json({ ok: true, message: "Telegram message sent!" });
123
+ }
124
+ catch (err) {
125
+ res.status(500).json({ error: String(err) });
126
+ }
127
+ });
128
+ app.get("/api/telegram/chatid", async (req, res) => {
129
+ const token = req.query.token ?? loadConfig().telegram?.token;
130
+ if (!token || token === "••••••••") {
131
+ res.status(400).json({ error: "Token required" });
132
+ return;
133
+ }
134
+ try {
135
+ const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates`);
136
+ const json = await r.json();
137
+ const chatId = json.result?.[0]?.message?.chat?.id?.toString();
138
+ if (!chatId) {
139
+ res.status(404).json({ error: "No messages yet — send any message to your bot first" });
140
+ return;
141
+ }
142
+ res.json({ chatId });
143
+ }
144
+ catch (err) {
145
+ res.status(500).json({ error: String(err) });
146
+ }
147
+ });
148
+ app.post("/api/test/whatsapp", async (_req, res) => {
149
+ const config = loadConfig();
150
+ const { instanceId, apiToken, phone } = config.whatsapp ?? {};
151
+ if (!instanceId || !apiToken || !phone) {
152
+ res.status(400).json({ error: "Instance ID, API token and phone are required." });
153
+ return;
154
+ }
155
+ try {
156
+ const r = await fetch(`https://api.green-api.com/waInstance${instanceId}/sendMessage/${apiToken}`, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify({ chatId: `${phone}@c.us`, message: "Test from Claude Notify — WhatsApp is working!" }),
160
+ });
161
+ if (!r.ok)
162
+ throw new Error(`Green API ${r.status}: ${await r.text()}`);
163
+ res.json({ ok: true, message: "WhatsApp message sent!" });
164
+ }
165
+ catch (err) {
166
+ res.status(500).json({ error: String(err) });
167
+ }
168
+ });
169
+ app.post("/api/test/sms", async (_req, res) => {
170
+ const config = loadConfig();
171
+ const { accountSid, authToken, from, to } = config.sms ?? {};
172
+ if (!accountSid || !authToken || !from || !to) {
173
+ res.status(400).json({ error: "All SMS fields are required." });
174
+ return;
175
+ }
176
+ try {
177
+ const client = twilio(accountSid, authToken);
178
+ await client.messages.create({ body: "Test from Claude Notify — SMS is working!", from, to });
179
+ res.json({ ok: true, message: `SMS sent to ${to}` });
180
+ }
181
+ catch (err) {
182
+ res.status(500).json({ error: String(err) });
183
+ }
184
+ });
185
+ app.post("/api/test/email", async (_req, res) => {
186
+ const config = loadConfig();
187
+ const email = config.email ?? {};
188
+ if (!email.to) {
189
+ res.status(400).json({ error: "No recipient address configured." });
190
+ return;
191
+ }
192
+ try {
193
+ let transport;
194
+ if (email.refreshToken && email.clientId && email.clientSecret) {
195
+ transport = nodemailer.createTransport({
196
+ service: "gmail",
197
+ auth: {
198
+ type: "OAuth2",
199
+ user: email.connectedEmail ?? email.to,
200
+ clientId: email.clientId,
201
+ clientSecret: email.clientSecret,
202
+ refreshToken: email.refreshToken,
203
+ accessToken: email.accessToken,
204
+ },
205
+ });
206
+ }
207
+ else if (email.host && email.user && email.pass) {
208
+ transport = nodemailer.createTransport({
209
+ host: email.host,
210
+ port: email.port ?? 587,
211
+ secure: email.secure ?? false,
212
+ auth: { user: email.user, pass: email.pass },
213
+ });
214
+ }
215
+ else {
216
+ res.status(400).json({ error: "Email not fully configured. Connect Gmail or set SMTP." });
217
+ return;
218
+ }
219
+ await transport.sendMail({
220
+ from: email.connectedEmail ?? email.user ?? email.to,
221
+ to: email.to,
222
+ subject: "Claude Notify — test email",
223
+ text: "Test from Claude Notify — email is working!",
224
+ });
225
+ res.json({ ok: true, message: `Email sent to ${email.to}` });
226
+ }
227
+ catch (err) {
228
+ res.status(500).json({ error: String(err) });
229
+ }
230
+ });
231
+ // ── Google ADC auto-setup ─────────────────────────────────────────────────────
232
+ async function adcEmail() {
233
+ if (!existsSync(ADC_PATH))
234
+ return null;
235
+ try {
236
+ const adc = JSON.parse(readFileSync(ADC_PATH, "utf-8"));
237
+ if (!adc.refresh_token || !adc.client_id || !adc.client_secret)
238
+ return null;
239
+ const oauth2Client = new google.auth.OAuth2(adc.client_id, adc.client_secret);
240
+ oauth2Client.setCredentials({ refresh_token: adc.refresh_token });
241
+ const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
242
+ const { data } = await oauth2.userinfo.get();
243
+ return data.email ?? null;
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ app.get("/api/google/open-apppasswords", (_req, res) => {
250
+ open("https://myaccount.google.com/apppasswords").catch(() => { });
251
+ res.json({ ok: true });
252
+ });
253
+ app.post("/api/google/apppassword", async (req, res) => {
254
+ const { gmailAddress, appPassword } = req.body;
255
+ if (!gmailAddress || !appPassword) {
256
+ res.status(400).json({ error: "Gmail address and app password required" });
257
+ return;
258
+ }
259
+ try {
260
+ const transport = nodemailer.createTransport({
261
+ host: "smtp.gmail.com",
262
+ port: 587,
263
+ secure: false,
264
+ auth: { user: gmailAddress, pass: appPassword },
265
+ });
266
+ await transport.verify();
267
+ const cfg = loadConfig();
268
+ cfg.email = {
269
+ ...cfg.email,
270
+ host: "smtp.gmail.com",
271
+ port: 587,
272
+ secure: false,
273
+ user: gmailAddress,
274
+ pass: appPassword,
275
+ connectedEmail: gmailAddress,
276
+ to: cfg.email?.to || gmailAddress,
277
+ enabled: true,
278
+ };
279
+ saveConfig(cfg);
280
+ res.json({ ok: true });
281
+ }
282
+ catch (err) {
283
+ res.status(500).json({ error: String(err) });
284
+ }
285
+ });
286
+ // ── gcloud auth ───────────────────────────────────────────────────────────────
287
+ function gcloudStatus() {
288
+ const check = spawnSync("gcloud", ["--version"], { encoding: "utf-8" });
289
+ if (check.status !== 0)
290
+ return { installed: false, authenticated: false };
291
+ const list = spawnSync("gcloud", ["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"], { encoding: "utf-8" });
292
+ const account = list.stdout.trim().split("\n")[0];
293
+ return { installed: true, authenticated: !!account, account: account || undefined };
294
+ }
295
+ app.get("/api/gcloud/status", (_req, res) => {
296
+ res.json(gcloudStatus());
297
+ });
298
+ app.get("/api/gcloud/login", (req, res) => {
299
+ res.setHeader("Content-Type", "text/event-stream");
300
+ res.setHeader("Cache-Control", "no-cache");
301
+ res.setHeader("Connection", "keep-alive");
302
+ const send = (type, msg) => res.write(`data: ${JSON.stringify({ type, msg })}\n\n`);
303
+ const status = gcloudStatus();
304
+ if (!status.installed) {
305
+ send("error", "gcloud not found. Install: brew install --cask google-cloud-sdk");
306
+ res.end();
307
+ return;
308
+ }
309
+ if (status.authenticated) {
310
+ send("already_authed", status.account);
311
+ res.end();
312
+ return;
313
+ }
314
+ send("info", "Opening browser for Google login…");
315
+ const child = spawn("gcloud", ["auth", "login", "--brief"], {
316
+ stdio: ["ignore", "pipe", "pipe"],
317
+ });
318
+ child.stdout.on("data", (d) => {
319
+ for (const line of d.toString().split("\n").filter(Boolean))
320
+ send("log", line);
321
+ });
322
+ child.stderr.on("data", (d) => {
323
+ for (const line of d.toString().split("\n").filter(Boolean)) {
324
+ if (line.includes("Go to the following link"))
325
+ send("open_browser", line.replace("Go to the following link in your browser:", "").trim());
326
+ else
327
+ send("log", line);
328
+ }
329
+ });
330
+ child.on("close", (code) => {
331
+ if (code === 0) {
332
+ const after = gcloudStatus();
333
+ send("done", after.account ?? "Logged in");
334
+ }
335
+ else {
336
+ send("error", `gcloud auth login exited with code ${code}`);
337
+ }
338
+ res.end();
339
+ });
340
+ req.on("close", () => child.kill());
341
+ });
342
+ // ── Google OAuth ──────────────────────────────────────────────────────────────
343
+ app.get("/auth/google/start", (req, res) => {
344
+ const config = loadConfig();
345
+ const { clientId, clientSecret } = config.email ?? {};
346
+ if (!clientId || !clientSecret) {
347
+ res.redirect("/?error=missing_credentials");
348
+ return;
349
+ }
350
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, REDIRECT_URI);
351
+ const url = oauth2Client.generateAuthUrl({
352
+ access_type: "offline",
353
+ prompt: "consent",
354
+ scope: [
355
+ "https://mail.google.com/",
356
+ "https://www.googleapis.com/auth/userinfo.email",
357
+ ],
358
+ });
359
+ res.redirect(url);
360
+ });
361
+ app.get("/auth/google/callback", async (req, res) => {
362
+ const { code, error } = req.query;
363
+ if (error) {
364
+ res.redirect(`/?error=${encodeURIComponent(error)}`);
365
+ return;
366
+ }
367
+ const config = loadConfig();
368
+ const { clientId, clientSecret } = config.email ?? {};
369
+ try {
370
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, REDIRECT_URI);
371
+ const { tokens } = await oauth2Client.getToken(code);
372
+ oauth2Client.setCredentials(tokens);
373
+ const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
374
+ const { data } = await oauth2.userinfo.get();
375
+ config.email.refreshToken = tokens.refresh_token ?? config.email.refreshToken;
376
+ config.email.accessToken = tokens.access_token;
377
+ config.email.connectedEmail = data.email;
378
+ config.email.enabled = true;
379
+ saveConfig(config);
380
+ res.redirect("/?success=gmail_connected");
381
+ }
382
+ catch (err) {
383
+ res.redirect(`/?error=${encodeURIComponent(String(err))}`);
384
+ }
385
+ });
386
+ app.delete("/auth/google", (_req, res) => {
387
+ const config = loadConfig();
388
+ delete config.email.refreshToken;
389
+ delete config.email.accessToken;
390
+ delete config.email.connectedEmail;
391
+ config.email.enabled = false;
392
+ saveConfig(config);
393
+ res.json({ ok: true });
394
+ });
395
+ // ── Log buffer + SSE broadcast ────────────────────────────────────────────────
396
+ const LOG_BUFFER_SIZE = 500;
397
+ const logBuffer = [];
398
+ const logClients = new Set();
399
+ function log(direction, channel, text, client) {
400
+ const ts = new Date().toISOString();
401
+ const clientPart = client ? ` [${client}]` : "";
402
+ const entry = `[${ts}]${clientPart} ${direction} [${channel}] ${text}`;
403
+ console.log(entry);
404
+ logBuffer.push(entry);
405
+ if (logBuffer.length > LOG_BUFFER_SIZE)
406
+ logBuffer.shift();
407
+ for (const res of logClients) {
408
+ try {
409
+ res.write(`data: ${JSON.stringify(entry)}\n\n`);
410
+ }
411
+ catch { }
412
+ }
413
+ }
414
+ app.get("/api/logs", (req, res) => {
415
+ res.setHeader("Content-Type", "text/event-stream");
416
+ res.setHeader("Cache-Control", "no-cache");
417
+ res.setHeader("Connection", "keep-alive");
418
+ for (const entry of logBuffer) {
419
+ res.write(`data: ${JSON.stringify(entry)}\n\n`);
420
+ }
421
+ logClients.add(res);
422
+ req.on("close", () => logClients.delete(res));
423
+ });
424
+ // ── Notification sender ───────────────────────────────────────────────────────
425
+ async function sendNotification(message, priority, client) {
426
+ const cfg = loadConfig();
427
+ const results = [];
428
+ const errors = [];
429
+ const send = async (name, fn) => {
430
+ try {
431
+ await fn();
432
+ results.push(name);
433
+ log("→", name, message, client);
434
+ }
435
+ catch (err) {
436
+ const msg = err instanceof Error ? err.message : String(err);
437
+ errors.push(`${name}: ${msg}`);
438
+ log("→", name, `ERROR: ${msg}`, client);
439
+ }
440
+ };
441
+ if (priority !== "low") {
442
+ if (cfg.desktop?.enabled) {
443
+ await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: true }, (err) => err ? rej(err) : res())));
444
+ }
445
+ if (cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
446
+ await send("telegram", async () => {
447
+ const r = await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
448
+ method: "POST", headers: { "Content-Type": "application/json" },
449
+ body: JSON.stringify({ chat_id: cfg.telegram.chatId, text: message }),
450
+ });
451
+ if (!r.ok)
452
+ throw new Error(await r.text());
453
+ });
454
+ }
455
+ }
456
+ if (priority === "high") {
457
+ const sms = cfg.sms ?? {};
458
+ if (sms.enabled && sms.accountSid && sms.authToken && sms.from && sms.to) {
459
+ await send("sms", async () => {
460
+ const client = twilio(sms.accountSid, sms.authToken);
461
+ await client.messages.create({ body: message, from: sms.from, to: sms.to });
462
+ });
463
+ }
464
+ }
465
+ const email = cfg.email ?? {};
466
+ if (email.enabled && email.to) {
467
+ await send("email", async () => {
468
+ let transport;
469
+ if (email.refreshToken && email.clientId && email.clientSecret) {
470
+ transport = nodemailer.createTransport({
471
+ service: "gmail", auth: { type: "OAuth2", user: email.connectedEmail ?? email.to,
472
+ clientId: email.clientId, clientSecret: email.clientSecret,
473
+ refreshToken: email.refreshToken, accessToken: email.accessToken },
474
+ });
475
+ }
476
+ else if (email.host && email.user && email.pass) {
477
+ transport = nodemailer.createTransport({
478
+ host: email.host, port: email.port ?? 587, secure: email.secure ?? false,
479
+ auth: { user: email.user, pass: email.pass },
480
+ });
481
+ }
482
+ else
483
+ return;
484
+ await transport.sendMail({ from: email.connectedEmail ?? email.user ?? email.to,
485
+ to: email.to, subject: "Claude Notify", text: message });
486
+ });
487
+ }
488
+ return [
489
+ results.length ? `Sent via: ${results.join(", ")}` : null,
490
+ errors.length ? `Errors: ${errors.join("; ")}` : null,
491
+ ].filter(Boolean).join(" | ") || "No channels delivered";
492
+ }
493
+ function getLocalIp() {
494
+ for (const nets of Object.values(networkInterfaces())) {
495
+ for (const net of nets ?? []) {
496
+ if (net.family === "IPv4" && !net.internal)
497
+ return net.address;
498
+ }
499
+ }
500
+ return "localhost";
501
+ }
502
+ // ── Ask / reply + inbox system ────────────────────────────────────────────────
503
+ const pendingAsks = new Map();
504
+ const inboxQueue = [];
505
+ let tgPollOffset = -1;
506
+ async function initTgOffset(token) {
507
+ const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
508
+ const json = await r.json();
509
+ const results = json.result ?? [];
510
+ return results.length > 0 ? results[results.length - 1].update_id + 1 : 0;
511
+ }
512
+ async function startTelegramListener() {
513
+ while (true) {
514
+ try {
515
+ const cfg = loadConfig();
516
+ const { token, chatId } = cfg.telegram ?? {};
517
+ if (!token || !chatId) {
518
+ await new Promise(r => setTimeout(r, 5000));
519
+ continue;
520
+ }
521
+ if (tgPollOffset < 0) {
522
+ tgPollOffset = await initTgOffset(token);
523
+ log("·", "telegram", `listener ready, offset=${tgPollOffset}`);
524
+ }
525
+ const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${tgPollOffset}&timeout=10`);
526
+ const json = await r.json();
527
+ for (const update of json.result ?? []) {
528
+ tgPollOffset = update.update_id + 1;
529
+ const msg = update.message;
530
+ if (msg?.chat?.id?.toString() === chatId && msg.text) {
531
+ log("←", "telegram", msg.text);
532
+ const first = [...pendingAsks.entries()][0];
533
+ if (first) {
534
+ const [id, pending] = first;
535
+ clearTimeout(pending.timer);
536
+ pendingAsks.delete(id);
537
+ log("←", "ask:reply", msg.text);
538
+ pending.resolve(msg.text);
539
+ }
540
+ else {
541
+ inboxQueue.push({ text: msg.text, ts: new Date().toISOString() });
542
+ log("·", "inbox", msg.text);
543
+ }
544
+ }
545
+ }
546
+ }
547
+ catch (err) {
548
+ const msg = err instanceof Error ? err.message : String(err);
549
+ if (!msg.includes("terminated") && !msg.includes("aborted")) {
550
+ log("·", "telegram:error", msg);
551
+ }
552
+ await new Promise(r => setTimeout(r, 2000));
553
+ }
554
+ }
555
+ }
556
+ app.get("/reply/:token", (req, res) => {
557
+ const pending = pendingAsks.get(req.params.token);
558
+ res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
559
+ <style>body{font-family:sans-serif;background:#0a0a0b;color:#f0f0f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
560
+ .box{background:#111113;border:1px solid #222226;border-radius:9px;padding:24px;max-width:500px;width:90%}
561
+ h2{color:#7c6dfa;margin:0 0 16px}textarea{width:100%;background:#0d0d10;border:1px solid #222226;border-radius:7px;color:#f0f0f0;padding:8px;font-size:14px;resize:vertical;min-height:80px;box-sizing:border-box}
562
+ button{background:#7c6dfa;color:white;border:none;border-radius:7px;padding:8px 20px;font-size:14px;cursor:pointer;margin-top:10px}
563
+ .ok{color:#10b981;margin-top:12px}.err{color:#ef4444}</style></head>
564
+ <body><div class="box">${pending
565
+ ? `<h2>Reply to Claude</h2><textarea id="r" placeholder="Type your response…"></textarea>
566
+ <button onclick="send()">Send</button><div id="s"></div>
567
+ <script>async function send(){const r=document.getElementById('r').value.trim();if(!r)return;
568
+ const res=await fetch('/reply/${req.params.token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({response:r})});
569
+ const el=document.getElementById('s');el.textContent=res.ok?'✓ Sent!':'Error';el.className=res.ok?'ok':'err';}</script>`
570
+ : `<h2>Expired</h2><p class="err">This link has already been used or timed out.</p>`}</div></body></html>`);
571
+ });
572
+ app.post("/reply/:token", (req, res) => {
573
+ const pending = pendingAsks.get(req.params.token);
574
+ if (!pending) {
575
+ res.status(404).json({ error: "Expired" });
576
+ return;
577
+ }
578
+ clearTimeout(pending.timer);
579
+ pendingAsks.delete(req.params.token);
580
+ log("←", "web-reply", req.body.response);
581
+ pending.resolve(req.body.response);
582
+ res.json({ ok: true });
583
+ });
584
+ // ── MCP server ────────────────────────────────────────────────────────────────
585
+ function createMcpServer(clientId) {
586
+ const server = new McpServer({ name: "notify-mcp", version: "1.0.0" });
587
+ server.tool("notify", "Send a notification through configured channels (desktop, Telegram, SMS, email). " +
588
+ "Use for: task milestones, questions needing input, catastrophic findings, long task completion.", {
589
+ message: z.string().max(500).describe("Notification message, max 500 chars"),
590
+ priority: z.enum(["low", "normal", "high"]).default("normal")
591
+ .describe("low=email only; normal=desktop+telegram+email; high=all channels"),
592
+ }, async ({ message, priority }) => {
593
+ const summary = await sendNotification(message, priority, clientId);
594
+ if (inboxQueue.length === 0) {
595
+ return { content: [{ type: "text", text: summary }] };
596
+ }
597
+ const messages = inboxQueue.splice(0);
598
+ log("·", "poll", `${messages.length} message(s) drained via notify`, clientId);
599
+ const inbox = messages.map(m => `[${m.ts}] ${m.text}`).join("\n");
600
+ return { content: [{ type: "text", text: `${summary}\n\nINBOX:\n${inbox}` }] };
601
+ });
602
+ server.tool("ask", "Send a question and wait for the user's reply via Telegram or email. " +
603
+ "Use when Claude needs a decision before continuing — e.g. 'Should I delete these files?'", {
604
+ question: z.string().max(500).describe("The question to ask the user"),
605
+ timeout_seconds: z.number().min(30).max(3600).default(300)
606
+ .describe("How long to wait for a reply in seconds (default 5 min)"),
607
+ }, async ({ question, timeout_seconds = 300 }) => {
608
+ const token = randomUUID();
609
+ const ip = getLocalIp();
610
+ const replyUrl = `http://${ip}:${PORT}/reply/${token}`;
611
+ const cfg = loadConfig();
612
+ log("→", "ask:telegram", question, clientId);
613
+ if (cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
614
+ await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
615
+ method: "POST",
616
+ headers: { "Content-Type": "application/json" },
617
+ body: JSON.stringify({
618
+ chat_id: cfg.telegram.chatId,
619
+ text: `❓ [${clientId}] ${question}\n\nReply to this message with your answer.`,
620
+ }),
621
+ }).catch((err) => log("→", "ask:telegram", `ERROR: ${err}`, clientId));
622
+ }
623
+ const email = cfg.email ?? {};
624
+ if (email.enabled && email.to) {
625
+ try {
626
+ let transport;
627
+ if (email.refreshToken && email.clientId && email.clientSecret) {
628
+ transport = nodemailer.createTransport({
629
+ service: "gmail", auth: { type: "OAuth2", user: email.connectedEmail ?? email.to,
630
+ clientId: email.clientId, clientSecret: email.clientSecret,
631
+ refreshToken: email.refreshToken, accessToken: email.accessToken },
632
+ });
633
+ }
634
+ else if (email.host && email.user && email.pass) {
635
+ transport = nodemailer.createTransport({
636
+ host: email.host, port: email.port ?? 587, secure: email.secure ?? false,
637
+ auth: { user: email.user, pass: email.pass },
638
+ });
639
+ }
640
+ if (transport) {
641
+ await transport.sendMail({
642
+ from: email.connectedEmail ?? email.user ?? email.to,
643
+ to: email.to,
644
+ subject: `Claude asks: ${question.slice(0, 60)}`,
645
+ html: `<p style="font-size:16px">${question}</p>
646
+ <p><a href="${replyUrl}" style="background:#7c6dfa;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:8px">Reply to Claude</a></p>`,
647
+ });
648
+ log("→", "ask:email", `question sent to ${email.to}, reply URL: ${replyUrl}`, clientId);
649
+ }
650
+ }
651
+ catch (err) {
652
+ log("→", "ask:email", `ERROR: ${err instanceof Error ? err.message : String(err)}`, clientId);
653
+ }
654
+ }
655
+ log("→", "ask", `waiting for reply (timeout: ${timeout_seconds}s)`, clientId);
656
+ const reply = await new Promise((resolve, reject) => {
657
+ const timer = setTimeout(() => {
658
+ pendingAsks.delete(token);
659
+ reject(new Error(`No reply received within ${timeout_seconds}s`));
660
+ }, timeout_seconds * 1000);
661
+ pendingAsks.set(token, { resolve, timer });
662
+ });
663
+ log("←", "ask:reply", reply, clientId);
664
+ return { content: [{ type: "text", text: reply }] };
665
+ });
666
+ server.tool("poll", "Check for unsolicited messages the user sent on Telegram (not in response to an ask). " +
667
+ "Returns queued messages and clears the queue. Returns 'inbox:empty' if nothing pending.", {}, async () => {
668
+ if (inboxQueue.length === 0) {
669
+ return { content: [{ type: "text", text: "inbox:empty" }] };
670
+ }
671
+ const messages = inboxQueue.splice(0);
672
+ log("·", "poll", `${messages.length} message(s) drained`, clientId);
673
+ return {
674
+ content: [{
675
+ type: "text",
676
+ text: messages.map(m => `[${m.ts}] ${m.text}`).join("\n"),
677
+ }],
678
+ };
679
+ });
680
+ return server;
681
+ }
682
+ const httpTransports = {};
683
+ app.all("/mcp", async (req, res) => {
684
+ const existingSessionId = req.headers["mcp-session-id"];
685
+ if (existingSessionId && httpTransports[existingSessionId]) {
686
+ await httpTransports[existingSessionId].handleRequest(req, res, req.body);
687
+ return;
688
+ }
689
+ const newSessionId = randomUUID();
690
+ const clientId = `sess-${newSessionId.slice(0, 8)}`;
691
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
692
+ transport.onclose = () => {
693
+ if (transport.sessionId)
694
+ delete httpTransports[transport.sessionId];
695
+ };
696
+ await createMcpServer(clientId).connect(transport);
697
+ await transport.handleRequest(req, res, req.body);
698
+ if (transport.sessionId)
699
+ httpTransports[transport.sessionId] = transport;
700
+ });
701
+ // ── Start ─────────────────────────────────────────────────────────────────────
702
+ app.listen(PORT, "0.0.0.0", () => {
703
+ const ip = getLocalIp();
704
+ console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
705
+ console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
706
+ startTelegramListener();
707
+ open(`http://localhost:${PORT}`).catch(() => { });
708
+ });