omni-notify-mcp 1.1.4 → 1.1.6

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/README.md CHANGED
@@ -86,7 +86,7 @@ Priority routing for `notify`:
86
86
  ## Features
87
87
 
88
88
  ### Channels
89
- - **Desktop** — native `node-notifier` (macOS/Windows/Linux). Per-channel **system-sound toggle**.
89
+ - **Desktop** — native `node-notifier` (macOS/Windows/Linux). Per-channel **system-sound toggle** and optional **text-to-speech** with a voice picker covering 30+ neural voices (US/UK/AU/CA/IN/IE/NZ/…) via `msedge-tts`, no API key.
90
90
  - **Telegram** — bidirectional. The bot **replies in-thread** to user messages and acknowledges every inbound message so the user knows it landed.
91
91
  - **SMS** — Twilio.
92
92
  - **Email** — Gmail App Password (one click) or any SMTP. `ask` over email sends a reply link the user clicks to answer.
Binary file
Binary file
@@ -1,5 +1,8 @@
1
1
  import notifier from "node-notifier";
2
2
  import { spawn } from "child_process";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ const DEFAULT_TTS_VOICE = "en-US-AndrewMultilingualNeural";
3
6
  export async function sendDesktop(config, message) {
4
7
  if (!config.enabled)
5
8
  return;
@@ -12,7 +15,34 @@ export async function sendDesktop(config, message) {
12
15
  "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
13
16
  ], { windowsHide: true, stdio: "ignore" });
14
17
  }
18
+ if (config.tts) {
19
+ // Fire-and-forget: synthesize and play in the background so we don't
20
+ // block the MCP request on network + audio playback.
21
+ speak(message, config.ttsVoice ?? DEFAULT_TTS_VOICE).catch(() => { });
22
+ }
15
23
  await new Promise((resolve, reject) => {
16
24
  notifier.notify({ title: "Claude", message, sound: wantSound && process.platform !== "win32" }, (err) => err ? reject(err) : resolve());
17
25
  });
18
26
  }
27
+ export async function speak(text, voice = DEFAULT_TTS_VOICE) {
28
+ // Lazy import so boot doesn't pay for msedge-tts when TTS is off.
29
+ const mod = await import("msedge-tts");
30
+ const { MsEdgeTTS, OUTPUT_FORMAT } = mod;
31
+ const tts = new MsEdgeTTS();
32
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
33
+ const { mkdtempSync } = await import("fs");
34
+ const outDir = mkdtempSync(join(tmpdir(), "notify-tts-"));
35
+ const { audioFilePath } = await tts.toFile(outDir, text);
36
+ if (process.platform === "win32") {
37
+ spawn("powershell", [
38
+ "-NoProfile", "-Command",
39
+ `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -Seconds 10`,
40
+ ], { windowsHide: true, stdio: "ignore" });
41
+ }
42
+ else if (process.platform === "darwin") {
43
+ spawn("afplay", [audioFilePath], { stdio: "ignore" });
44
+ }
45
+ else {
46
+ spawn("aplay", [audioFilePath], { stdio: "ignore" });
47
+ }
48
+ }
package/dist/ui/server.js CHANGED
@@ -13,6 +13,7 @@ import twilio from "twilio";
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
15
  import { z } from "zod";
16
+ import { tmpdir } from "os";
16
17
  const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
17
18
  const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
18
19
  const PUBLIC_DIR = join(fileURLToPath(new URL("../../ui/public", import.meta.url)));
@@ -168,6 +169,60 @@ app.post("/api/test/sound", (_req, res) => {
168
169
  res.json({ ok: true, message: "System sound triggered" });
169
170
  });
170
171
  });
172
+ async function speakText(text, voice) {
173
+ const mod = await import("msedge-tts");
174
+ const { MsEdgeTTS, OUTPUT_FORMAT } = mod;
175
+ const tts = new MsEdgeTTS();
176
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
177
+ const { mkdtempSync } = await import("fs");
178
+ const outDir = mkdtempSync(join(tmpdir(), "notify-tts-"));
179
+ const { audioFilePath } = await tts.toFile(outDir, text);
180
+ if (process.platform === "win32") {
181
+ spawn("powershell", [
182
+ "-NoProfile", "-Command",
183
+ `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -Seconds 10`,
184
+ ], { windowsHide: true, stdio: "ignore" });
185
+ }
186
+ else if (process.platform === "darwin") {
187
+ spawn("afplay", [audioFilePath], { stdio: "ignore" });
188
+ }
189
+ else {
190
+ spawn("aplay", [audioFilePath], { stdio: "ignore" });
191
+ }
192
+ }
193
+ app.post("/api/test/tts", async (req, res) => {
194
+ try {
195
+ const cfg = loadConfig();
196
+ const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
197
+ cfg.desktop?.ttsVoice ||
198
+ "en-US-AndrewMultilingualNeural";
199
+ await speakText("Notification from Claude. This is a voice test.", voice);
200
+ res.json({ ok: true, message: `TTS played (${voice})` });
201
+ }
202
+ catch (err) {
203
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
204
+ }
205
+ });
206
+ let voiceCache = null;
207
+ app.get("/api/voices", async (_req, res) => {
208
+ try {
209
+ if (!voiceCache || Date.now() - voiceCache.ts > 24 * 60 * 60 * 1000) {
210
+ const mod = await import("msedge-tts");
211
+ const tts = new mod.MsEdgeTTS();
212
+ const all = await tts.getVoices();
213
+ voiceCache = {
214
+ ts: Date.now(),
215
+ voices: all
216
+ .filter((v) => v.Locale.startsWith("en-") && v.ShortName.includes("Neural"))
217
+ .map((v) => ({ shortName: v.ShortName, gender: v.Gender, locale: v.Locale })),
218
+ };
219
+ }
220
+ res.json({ voices: voiceCache.voices });
221
+ }
222
+ catch (err) {
223
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
224
+ }
225
+ });
171
226
  app.post("/api/test/desktop", (_req, res) => {
172
227
  const time = new Date().toLocaleTimeString();
173
228
  const cfg = loadConfig();
@@ -568,6 +623,10 @@ async function sendNotification(message, priority, client) {
568
623
  ], { windowsHide: true, stdio: "ignore" });
569
624
  }
570
625
  const soundOpt = wantSound && process.platform !== "win32";
626
+ if (cfg.desktop?.tts) {
627
+ const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
628
+ speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
629
+ }
571
630
  await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => err ? rej(err) : res())));
572
631
  }
573
632
  if (!desktopOnlyMode && cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",
@@ -53,6 +53,7 @@
53
53
  "@modelcontextprotocol/sdk": "^1.0.0",
54
54
  "express": "^4.19.2",
55
55
  "googleapis": "^144.0.0",
56
+ "msedge-tts": "^2.0.5",
56
57
  "node-notifier": "^10.0.1",
57
58
  "nodemailer": "^6.9.9",
58
59
  "open": "^10.1.0",
package/ui/public/app.js CHANGED
@@ -44,6 +44,9 @@ function populateForm() {
44
44
  // Desktop
45
45
  $("desktop-enabled").checked = !!config.desktop?.enabled;
46
46
  $("desktop-sound").checked = config.desktop?.sound !== false; // default on
47
+ $("desktop-tts").checked = !!config.desktop?.tts; // default off
48
+ updateTtsVoiceRow();
49
+ loadVoices().catch(() => {});
47
50
 
48
51
  // Email / Gmail
49
52
  const email = config.email ?? {};
@@ -150,14 +153,49 @@ function setBadge(channel, type, text) {
150
153
  // ── Save handlers ─────────────────────────────────────────────────────────
151
154
 
152
155
  function saveDesktop() {
156
+ updateTtsVoiceRow();
157
+ const ttsVoice = $("desktop-tts-voice").value || undefined;
153
158
  patch({
154
159
  desktop: {
155
160
  enabled: $("desktop-enabled").checked,
156
161
  sound: $("desktop-sound").checked,
162
+ tts: $("desktop-tts").checked,
163
+ ttsVoice,
157
164
  },
158
165
  });
159
166
  }
160
167
 
168
+ function updateTtsVoiceRow() {
169
+ const row = $("tts-voice-row");
170
+ row.style.display = $("desktop-tts").checked ? "" : "none";
171
+ }
172
+
173
+ let voicesLoaded = false;
174
+ async function loadVoices() {
175
+ if (voicesLoaded) return;
176
+ const res = await fetch("/api/voices");
177
+ if (!res.ok) return;
178
+ const { voices } = await res.json();
179
+ const sel = $("desktop-tts-voice");
180
+ const current = config.desktop?.ttsVoice || "en-US-AndrewMultilingualNeural";
181
+ const byLocale = {};
182
+ for (const v of voices) (byLocale[v.locale] ??= []).push(v);
183
+ sel.innerHTML = "";
184
+ for (const locale of Object.keys(byLocale).sort()) {
185
+ const og = document.createElement("optgroup");
186
+ og.label = locale;
187
+ for (const v of byLocale[locale].sort((a, b) => a.shortName.localeCompare(b.shortName))) {
188
+ const opt = document.createElement("option");
189
+ opt.value = v.shortName;
190
+ opt.textContent = `${v.shortName.replace(locale + "-", "")} (${v.gender})`;
191
+ if (v.shortName === current) opt.selected = true;
192
+ og.appendChild(opt);
193
+ }
194
+ sel.appendChild(og);
195
+ }
196
+ voicesLoaded = true;
197
+ }
198
+
161
199
  async function saveEmail() {
162
200
  const to = $("gmail-to-connected").value.trim();
163
201
  const enabled = $("email-enabled").checked;
@@ -398,6 +436,22 @@ async function testSound() {
398
436
  }
399
437
  }
400
438
 
439
+ async function testTts() {
440
+ try {
441
+ const voice = $("desktop-tts-voice").value || undefined;
442
+ const res = await fetch("/api/test/tts", {
443
+ method: "POST",
444
+ headers: { "Content-Type": "application/json" },
445
+ body: JSON.stringify({ voice }),
446
+ });
447
+ const json = await res.json();
448
+ if (!res.ok) throw new Error(json.error);
449
+ toast(json.message, "ok");
450
+ } catch (e) {
451
+ toast("TTS test failed: " + e, "error");
452
+ }
453
+ }
454
+
401
455
  // ── Test channels ─────────────────────────────────────────────────────────
402
456
 
403
457
  async function testChannel(channel) {
@@ -55,6 +55,17 @@
55
55
  <span class="toggle-lbl">Play system sound</span>
56
56
  <button class="btn btn-sm btn-ghost" onclick="testSound()">Test sound</button>
57
57
  </div>
58
+ <div class="actions" style="margin-top:6px">
59
+ <label class="toggle-wrap">
60
+ <input type="checkbox" id="desktop-tts" onchange="saveDesktop()">
61
+ <span class="toggle"></span>
62
+ </label>
63
+ <span class="toggle-lbl">Speak notification</span>
64
+ <button class="btn btn-sm btn-ghost" onclick="testTts()">Test voice</button>
65
+ </div>
66
+ <div id="tts-voice-row" class="actions" style="margin-top:6px; display:none">
67
+ <select id="desktop-tts-voice" onchange="saveDesktop()" style="flex:1; min-width:0"></select>
68
+ </div>
58
69
  <span id="os-hint" class="os-tag hidden"></span>
59
70
  </div>
60
71
  </div>