omni-notify-mcp 1.3.7 → 1.3.11
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 +17 -9
- package/dist/index.js +45 -11
- package/dist/ui/messaging/notificationEngine.js +67 -0
- package/dist/ui/messaging/types.js +1 -0
- package/dist/ui/server.js +600 -222
- package/package.json +1 -1
- package/ui/public/app.js +1 -1
- package/ui/public/help.html +6 -3
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<em>Reach me on any channel. Ask me anything. Get out of my way when I'm busy.</em><br>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
HTTP-first notification/control server with optional MCP compatibility for AI agents
|
|
10
|
+
(Claude, Copilot, Cursor, etc.): desktop, Telegram, Slack, SMS, email,
|
|
11
|
+
two-way replies, idle gating, and Do Not Disturb.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
@@ -37,6 +37,20 @@ You step away from your machine and the AI is still working. **It needs to**:
|
|
|
37
37
|
|
|
38
38
|
## Quick start
|
|
39
39
|
|
|
40
|
+
```bash
|
|
41
|
+
npx omni-notify-ui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This starts the HTTP/UI server on `http://localhost:3737`.
|
|
45
|
+
|
|
46
|
+
MCP is optional. To enable the `/mcp` endpoint, start with:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ENABLE_MCP=1 npx omni-notify-ui
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or use the stdio bridge (which auto-spawns the UI with MCP enabled):
|
|
53
|
+
|
|
40
54
|
```bash
|
|
41
55
|
npx omni-notify-mcp
|
|
42
56
|
```
|
|
@@ -54,12 +68,6 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
|
|
|
54
68
|
}
|
|
55
69
|
```
|
|
56
70
|
|
|
57
|
-
Then run the config UI to wire up your channels:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
npx omni-notify-ui
|
|
61
|
-
```
|
|
62
|
-
|
|
63
71
|
Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
|
|
64
72
|
|
|
65
73
|
## What the agent gets
|
package/dist/index.js
CHANGED
|
@@ -27,12 +27,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
27
27
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
28
|
import { spawn } from "child_process";
|
|
29
29
|
import { existsSync } from "fs";
|
|
30
|
-
import { join, dirname } from "path";
|
|
30
|
+
import { join, dirname, basename } from "path";
|
|
31
|
+
import { hostname } from "os";
|
|
31
32
|
import { fileURLToPath } from "url";
|
|
32
33
|
import { z } from "zod";
|
|
33
34
|
const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
|
|
34
35
|
const BASE = `http://localhost:${PORT}`;
|
|
35
|
-
const
|
|
36
|
+
const VSC_ID = (process.env.NOTIFY_MCP_TAG || basename(process.cwd())).toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
37
|
+
const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}`;
|
|
36
38
|
const CLIENT_NAME = "claude-channel-bridge";
|
|
37
39
|
// ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
|
|
38
40
|
async function serverIsUp() {
|
|
@@ -43,9 +45,9 @@ async function serverIsUp() {
|
|
|
43
45
|
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
|
|
44
46
|
signal: AbortSignal.timeout(1500),
|
|
45
47
|
});
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
return r.status > 0;
|
|
48
|
+
// The bridge requires an active /mcp endpoint.
|
|
49
|
+
// 404 specifically means UI may be up but MCP transport is disabled.
|
|
50
|
+
return r.status > 0 && r.status !== 404;
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
53
|
return false;
|
|
@@ -68,7 +70,7 @@ function spawnUiServerIfNeeded() {
|
|
|
68
70
|
const child = spawn(process.execPath, [uiPath], {
|
|
69
71
|
detached: true,
|
|
70
72
|
stdio: "ignore",
|
|
71
|
-
env: { ...process.env, PORT: String(PORT) },
|
|
73
|
+
env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
|
|
72
74
|
});
|
|
73
75
|
child.unref();
|
|
74
76
|
}
|
|
@@ -159,6 +161,21 @@ async function httpInitialize() {
|
|
|
159
161
|
// Follow-up: the spec requires a notifications/initialized after initialize.
|
|
160
162
|
await httpRpc("notifications/initialized").catch(() => { });
|
|
161
163
|
}
|
|
164
|
+
// Periodically touch our /mcp session so the server's reaper doesn't prune it.
|
|
165
|
+
// Without this, the bridge opens a session on startup, proxies tool calls
|
|
166
|
+
// infrequently, and after 60s of idle the server wipes the session from its
|
|
167
|
+
// sessions[] map — which makes Telegram's "routed to X agents" ack say
|
|
168
|
+
// "no agents connected" even though the bridge is still alive and subscribed.
|
|
169
|
+
function startSessionKeepalive() {
|
|
170
|
+
setInterval(async () => {
|
|
171
|
+
try {
|
|
172
|
+
await httpRpc("tools/list").catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// best effort; next call will re-initialize via 404 retry path
|
|
176
|
+
}
|
|
177
|
+
}, 30_000);
|
|
178
|
+
}
|
|
162
179
|
// ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
|
|
163
180
|
const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
164
181
|
// Declare the claude/channel capability so Claude Code knows to surface
|
|
@@ -168,10 +185,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
168
185
|
capabilities: {
|
|
169
186
|
experimental: { "claude/channel": {} },
|
|
170
187
|
},
|
|
171
|
-
instructions: "This is the stdio bridge for notify-mcp.
|
|
172
|
-
"messages
|
|
173
|
-
"
|
|
174
|
-
"
|
|
188
|
+
instructions: "This is the stdio bridge for notify-mcp. To receive unsolicited user " +
|
|
189
|
+
"messages in ANY MCP host (Claude Code, Copilot, Cursor), call `wait_for_inbox` " +
|
|
190
|
+
"— it long-polls up to 50s and returns new user messages as tool results. Call " +
|
|
191
|
+
"it whenever you finish a task or would otherwise wait, then again right after " +
|
|
192
|
+
"handling each batch, so messages keep arriving. (The Claude Code CLI launched " +
|
|
193
|
+
"with `--channels` ALSO delivers messages as synthetic turns automatically, but " +
|
|
194
|
+
"that path does NOT exist in the VSCode extension or Copilot — `wait_for_inbox` " +
|
|
195
|
+
"is the reliable lowest-common-denominator mechanism across every host.)\n\n" +
|
|
175
196
|
"ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
|
|
176
197
|
"just finished (success or failure), (b) you have a question or need a " +
|
|
177
198
|
"decision, (c) something important happened the user needs to know right " +
|
|
@@ -182,6 +203,15 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
182
203
|
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
204
|
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
205
|
"Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
|
|
206
|
+
"🚨 500-CHAR LIMIT — CHUNK, NEVER TRUNCATE 🚨\n" +
|
|
207
|
+
"The `notify` tool rejects bodies > 500 chars with `MCP error -32602: too_big`. " +
|
|
208
|
+
"When you have more to say than fits in 500 chars, you MUST split into MULTIPLE " +
|
|
209
|
+
"notify calls — do NOT silently shorten the body. Procedure: (1) decide what " +
|
|
210
|
+
"the user MUST see (every fact, file path, line number, recommendation); (2) if " +
|
|
211
|
+
"the body exceeds 500 chars, split into N chunks numbered '(1/N) ...', '(2/N) ...', " +
|
|
212
|
+
"each ≤ 500 chars including the prefix; (3) send all N chunks in order via separate " +
|
|
213
|
+
"notify calls and echo all N IN FULL in chat. NEVER respond to a `too_big` error by " +
|
|
214
|
+
"shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
|
|
185
215
|
"When the user asks you to remember a behavioral rule or change how you should act, " +
|
|
186
216
|
"call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
|
|
187
217
|
"so the instructions persist across sessions and context compaction.",
|
|
@@ -200,7 +230,10 @@ async function proxyToolCall(name, args) {
|
|
|
200
230
|
}
|
|
201
231
|
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
202
232
|
}
|
|
203
|
-
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured."
|
|
233
|
+
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
234
|
+
"MAX 500 CHARS PER MESSAGE. If you have more to say, split into multiple notify " +
|
|
235
|
+
"calls with '(1/N) ...', '(2/N) ...' prefixes — never silently shorten on " +
|
|
236
|
+
"`too_big` error; that loses information the user needs.", {
|
|
204
237
|
message: z.string().max(500),
|
|
205
238
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
206
239
|
}, async (args) => proxyToolCall("notify", args));
|
|
@@ -323,6 +356,7 @@ async function main() {
|
|
|
323
356
|
// Fire-and-forget: the stdio transport should be usable immediately; the
|
|
324
357
|
// push channel attaches as soon as the SSE handshake completes.
|
|
325
358
|
subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
|
|
359
|
+
startSessionKeepalive();
|
|
326
360
|
const transport = new StdioServerTransport();
|
|
327
361
|
await server.connect(transport);
|
|
328
362
|
stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function computeDesktopOnlyMode(priority, policy, ctx) {
|
|
2
|
+
if (priority === "high") {
|
|
3
|
+
return { desktopOnly: false };
|
|
4
|
+
}
|
|
5
|
+
if (policy.dndActive) {
|
|
6
|
+
return { desktopOnly: false, suppressedReason: "dnd" };
|
|
7
|
+
}
|
|
8
|
+
if (ctx.uiActive || (policy.idleEnabled && !ctx.inTelegramConversation && ctx.idleSeconds >= 0 && ctx.idleSeconds < policy.idleThresholdSeconds)) {
|
|
9
|
+
if (policy.alwaysDesktopWhenActive) {
|
|
10
|
+
return { desktopOnly: true };
|
|
11
|
+
}
|
|
12
|
+
return { desktopOnly: false, suppressedReason: "idle" };
|
|
13
|
+
}
|
|
14
|
+
return { desktopOnly: false };
|
|
15
|
+
}
|
|
16
|
+
export async function sendWithRouting(options) {
|
|
17
|
+
const { message, priority, senders, policy, ctx, enableDesktop, enableTelegram, enableEmail, enableSms, enableNtfy, enableDiscord, enableSlack, enableTeams, } = options;
|
|
18
|
+
const mode = computeDesktopOnlyMode(priority, policy, ctx);
|
|
19
|
+
if (mode.suppressedReason === "dnd") {
|
|
20
|
+
return { delivered: [], errors: [], suppressedReason: "DND active" };
|
|
21
|
+
}
|
|
22
|
+
if (mode.suppressedReason === "idle") {
|
|
23
|
+
return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
|
|
24
|
+
}
|
|
25
|
+
const delivered = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
const desktopOnly = mode.desktopOnly;
|
|
28
|
+
const trySend = async (name, fn) => {
|
|
29
|
+
if (!fn)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
await fn();
|
|
33
|
+
delivered.push(name);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
errors.push(`${name}: ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if (priority !== "low") {
|
|
41
|
+
if (enableDesktop) {
|
|
42
|
+
await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
|
|
43
|
+
}
|
|
44
|
+
if (!desktopOnly && enableTelegram) {
|
|
45
|
+
await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!desktopOnly && enableEmail) {
|
|
49
|
+
await trySend("email", senders.email ? () => senders.email(message) : undefined);
|
|
50
|
+
}
|
|
51
|
+
if (!desktopOnly && enableNtfy) {
|
|
52
|
+
await trySend("ntfy", senders.ntfy ? () => senders.ntfy(message, priority) : undefined);
|
|
53
|
+
}
|
|
54
|
+
if (!desktopOnly && enableDiscord) {
|
|
55
|
+
await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
|
|
56
|
+
}
|
|
57
|
+
if (!desktopOnly && enableSlack) {
|
|
58
|
+
await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
|
|
59
|
+
}
|
|
60
|
+
if (!desktopOnly && enableTeams) {
|
|
61
|
+
await trySend("teams", senders.teams ? () => senders.teams(message, priority) : undefined);
|
|
62
|
+
}
|
|
63
|
+
if (priority === "high" && enableSms) {
|
|
64
|
+
await trySend("sms", senders.sms ? () => senders.sms(message) : undefined);
|
|
65
|
+
}
|
|
66
|
+
return { delivered, errors };
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
2
|
+
import { randomUUID, createHmac, timingSafeEqual } from "crypto";
|
|
3
3
|
import express from "express";
|
|
4
4
|
import { google } from "googleapis";
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
@@ -11,16 +11,20 @@ import open from "open";
|
|
|
11
11
|
import notifier from "node-notifier";
|
|
12
12
|
import nodemailer from "nodemailer";
|
|
13
13
|
import twilio from "twilio";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { sendWithRouting } from "./messaging/notificationEngine.js";
|
|
16
|
+
import { z } from "zod";
|
|
14
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
18
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
|
-
import { z } from "zod";
|
|
17
|
-
import { tmpdir } from "os";
|
|
18
19
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
|
|
19
20
|
const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
|
|
20
21
|
const PUBLIC_DIR = join(fileURLToPath(new URL("../../ui/public", import.meta.url)));
|
|
21
22
|
const CONFIG_DIR = join(homedir(), ".notify-mcp");
|
|
22
23
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
23
24
|
const ADC_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
|
|
25
|
+
const AGENT_API_KEY = (process.env.NOTIFY_AGENT_KEY ?? "").trim();
|
|
26
|
+
const SLACK_SIGNING_SECRET = (process.env.SLACK_SIGNING_SECRET ?? "").trim();
|
|
27
|
+
const ENABLE_MCP = (process.env.ENABLE_MCP ?? "").trim() === "1";
|
|
24
28
|
function defaultConfig() {
|
|
25
29
|
return {
|
|
26
30
|
desktop: { enabled: false, sound: true },
|
|
@@ -141,8 +145,52 @@ function mergePreservingSecrets(existing, update) {
|
|
|
141
145
|
return merged;
|
|
142
146
|
}
|
|
143
147
|
const app = express();
|
|
144
|
-
app.use(
|
|
148
|
+
app.use((req, _res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} UA:${req.headers['user-agent']?.slice(0, 60)}`); next(); });
|
|
149
|
+
app.use(express.json({
|
|
150
|
+
verify: (req, _res, buf) => {
|
|
151
|
+
req.rawBody = Buffer.from(buf);
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
app.use(express.urlencoded({
|
|
155
|
+
extended: true,
|
|
156
|
+
verify: (req, _res, buf) => {
|
|
157
|
+
const r = req;
|
|
158
|
+
if (!r.rawBody)
|
|
159
|
+
r.rawBody = Buffer.from(buf);
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
145
162
|
app.use(express.static(PUBLIC_DIR));
|
|
163
|
+
function requireAgentAuth(req, res) {
|
|
164
|
+
if (!AGENT_API_KEY)
|
|
165
|
+
return true;
|
|
166
|
+
const got = String(req.headers["x-notify-key"] ?? "").trim();
|
|
167
|
+
if (!got || got !== AGENT_API_KEY) {
|
|
168
|
+
res.status(401).json({ error: "unauthorized" });
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
function verifySlackSignature(req) {
|
|
174
|
+
if (!SLACK_SIGNING_SECRET)
|
|
175
|
+
return true;
|
|
176
|
+
const sig = String(req.headers["x-slack-signature"] ?? "");
|
|
177
|
+
const ts = String(req.headers["x-slack-request-timestamp"] ?? "");
|
|
178
|
+
if (!sig || !ts)
|
|
179
|
+
return false;
|
|
180
|
+
const tsNum = parseInt(ts, 10);
|
|
181
|
+
if (!Number.isFinite(tsNum))
|
|
182
|
+
return false;
|
|
183
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - tsNum) > 300)
|
|
184
|
+
return false;
|
|
185
|
+
const rawBody = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
|
|
186
|
+
const base = `v0:${ts}:${rawBody}`;
|
|
187
|
+
const expected = `v0=${createHmac("sha256", SLACK_SIGNING_SECRET).update(base).digest("hex")}`;
|
|
188
|
+
const a = Buffer.from(expected, "utf8");
|
|
189
|
+
const b = Buffer.from(sig, "utf8");
|
|
190
|
+
if (a.length !== b.length)
|
|
191
|
+
return false;
|
|
192
|
+
return timingSafeEqual(a, b);
|
|
193
|
+
}
|
|
146
194
|
const ntfySubscribers = new Map();
|
|
147
195
|
function ntfyFanout(topic, message, title, priority, tags) {
|
|
148
196
|
const subs = ntfySubscribers.get(topic);
|
|
@@ -205,6 +253,9 @@ function handleNtfyJson(req, res) {
|
|
|
205
253
|
}, 30_000);
|
|
206
254
|
req.on("close", () => { clearInterval(keepalive); ntfySubscribers.get(topic)?.delete(sub); });
|
|
207
255
|
}
|
|
256
|
+
// ntfy health + info endpoints — app checks these before subscribing
|
|
257
|
+
app.get("/v1/health", (_req, res) => res.json({ healthy: true }));
|
|
258
|
+
app.get("/v1/info", (_req, res) => res.json({ version: "2.11.0", sha: "n/a" }));
|
|
208
259
|
// ntfy app hits /:topic/sse or /:topic/json (no /ntfy/ prefix)
|
|
209
260
|
app.get("/:topic/sse", (req, res) => {
|
|
210
261
|
if (["api", "auth", "mcp", "assets", "ntfy"].includes(req.params.topic)) {
|
|
@@ -345,7 +396,8 @@ app.post("/api/test/tts", async (req, res) => {
|
|
|
345
396
|
const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
|
|
346
397
|
cfg.desktop?.ttsVoice ||
|
|
347
398
|
"en-US-AndrewMultilingualNeural";
|
|
348
|
-
|
|
399
|
+
const text = (typeof req.body?.text === "string" && req.body.text.trim()) || "Notification from Claude. This is a voice test.";
|
|
400
|
+
await speakText(text, voice);
|
|
349
401
|
res.json({ ok: true, message: `TTS played (${voice})` });
|
|
350
402
|
}
|
|
351
403
|
catch (err) {
|
|
@@ -772,33 +824,18 @@ function log(direction, channel, text, client) {
|
|
|
772
824
|
}
|
|
773
825
|
}
|
|
774
826
|
app.get("/api/sessions", (_req, res) => {
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
connectedAt: s.connectedAt,
|
|
783
|
-
lastSeen: s.lastSeen,
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
const list = [...inboxStreamClients].map((c, i) => ({
|
|
829
|
+
clientId: `sse-${i + 1}`,
|
|
830
|
+
tag: c.tag,
|
|
831
|
+
transport: "sse",
|
|
832
|
+
connectedAt: now,
|
|
833
|
+
lastSeen: now,
|
|
784
834
|
}));
|
|
785
835
|
res.json({ sessions: list });
|
|
786
836
|
});
|
|
787
|
-
app.delete("/api/sessions/:clientId", (
|
|
788
|
-
|
|
789
|
-
const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
|
|
790
|
-
if (!entry) {
|
|
791
|
-
res.status(404).json({ error: "not found" });
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
const [sessionId] = entry;
|
|
795
|
-
try {
|
|
796
|
-
httpTransports[sessionId]?.close();
|
|
797
|
-
}
|
|
798
|
-
catch { /* ignore */ }
|
|
799
|
-
delete httpTransports[sessionId];
|
|
800
|
-
delete sessions[sessionId];
|
|
801
|
-
res.json({ ok: true });
|
|
837
|
+
app.delete("/api/sessions/:clientId", (_req, res) => {
|
|
838
|
+
res.status(410).json({ error: "session_disconnect_unsupported", message: "HTTP mode does not support remote disconnect." });
|
|
802
839
|
});
|
|
803
840
|
app.get("/api/logs", (req, res) => {
|
|
804
841
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -813,198 +850,149 @@ app.get("/api/logs", (req, res) => {
|
|
|
813
850
|
// ── Notification sender ───────────────────────────────────────────────────────
|
|
814
851
|
async function sendNotification(message, priority, client) {
|
|
815
852
|
const cfg = loadConfig();
|
|
816
|
-
const results = [];
|
|
817
|
-
const errors = [];
|
|
818
|
-
// DND check — priority=high always bypasses; anything else gets dropped during quiet hours.
|
|
819
|
-
// Email still goes through on "low" anyway (historical behavior: low=email-only).
|
|
820
|
-
if (priority !== "high" && isDndActive(cfg)) {
|
|
821
|
-
log("·", "dnd", `suppressed ${priority} notif (DND active)`, client);
|
|
822
|
-
return "DND active — notif suppressed (priority=high would still send)";
|
|
823
|
-
}
|
|
824
|
-
// Idle gating — when the user is actively at the keyboard, suppress *remote*
|
|
825
|
-
// channels (Telegram/SMS/email) for non-high priority. By default we still
|
|
826
|
-
// play the desktop sound+banner so the user knows *something* happened —
|
|
827
|
-
// they may have multiple agents running and this is a cheap local signal
|
|
828
|
-
// that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
|
|
829
|
-
// priority=high always bypasses idle entirely.
|
|
830
|
-
// Conversation bypass: if the user just messaged us from Telegram (within
|
|
831
|
-
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
832
853
|
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
// SystemSounds.Asterisk plays through the actual sound card via
|
|
881
|
-
// the Windows notification sound, audible on every machine.
|
|
882
|
-
spawn("powershell", [
|
|
883
|
-
"-NoProfile", "-Command",
|
|
884
|
-
"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
|
|
885
|
-
], { windowsHide: true, stdio: "ignore" });
|
|
886
|
-
}
|
|
887
|
-
const soundOpt = wantSound && process.platform !== "win32";
|
|
888
|
-
if (cfg.desktop?.tts) {
|
|
889
|
-
const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
|
|
890
|
-
speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
|
|
891
|
-
}
|
|
892
|
-
await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => err ? rej(err) : res())));
|
|
893
|
-
}
|
|
894
|
-
if (!desktopOnlyMode && cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
|
|
895
|
-
await send("telegram", async () => {
|
|
854
|
+
const idleSecs = getOsIdleSeconds();
|
|
855
|
+
const result = await sendWithRouting({
|
|
856
|
+
message,
|
|
857
|
+
priority,
|
|
858
|
+
policy: {
|
|
859
|
+
idleEnabled: cfg.idle?.enabled !== false,
|
|
860
|
+
idleThresholdSeconds: cfg.idle?.thresholdSeconds ?? 120,
|
|
861
|
+
alwaysDesktopWhenActive: cfg.idle?.alwaysDesktopWhenActive !== false,
|
|
862
|
+
dndActive: isDndActive(cfg),
|
|
863
|
+
},
|
|
864
|
+
ctx: {
|
|
865
|
+
inTelegramConversation: inTelegramConvo,
|
|
866
|
+
uiActive: isUiActivelyOpen(),
|
|
867
|
+
idleSeconds: idleSecs,
|
|
868
|
+
},
|
|
869
|
+
enableDesktop: !!cfg.desktop?.enabled,
|
|
870
|
+
enableTelegram: !!(cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId),
|
|
871
|
+
enableEmail: !!(cfg.email?.enabled && cfg.email.to),
|
|
872
|
+
enableSms: !!(cfg.sms?.enabled && cfg.sms.accountSid && cfg.sms.authToken && cfg.sms.from && cfg.sms.to),
|
|
873
|
+
enableNtfy: !!(cfg.ntfy?.enabled && cfg.ntfy.topic),
|
|
874
|
+
enableDiscord: !!(cfg.discord?.enabled && cfg.discord.webhookUrl),
|
|
875
|
+
enableSlack: !!(cfg.slack?.enabled && cfg.slack.webhookUrl),
|
|
876
|
+
enableTeams: !!(cfg.teams?.enabled && cfg.teams.webhookUrl),
|
|
877
|
+
senders: {
|
|
878
|
+
desktop: async () => {
|
|
879
|
+
const wantSound = cfg.desktop?.sound !== false;
|
|
880
|
+
if (wantSound && process.platform === "win32") {
|
|
881
|
+
spawn("powershell", [
|
|
882
|
+
"-NoProfile", "-Command",
|
|
883
|
+
"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
|
|
884
|
+
], { windowsHide: true, stdio: "ignore" });
|
|
885
|
+
}
|
|
886
|
+
const soundOpt = wantSound && process.platform !== "win32";
|
|
887
|
+
if (cfg.desktop?.tts) {
|
|
888
|
+
const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
|
|
889
|
+
speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
|
|
890
|
+
}
|
|
891
|
+
await new Promise((resolve, reject) => {
|
|
892
|
+
notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => {
|
|
893
|
+
if (err)
|
|
894
|
+
reject(err);
|
|
895
|
+
else
|
|
896
|
+
resolve();
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
telegram: async () => {
|
|
896
901
|
const body = { chat_id: cfg.telegram.chatId, text: message };
|
|
897
902
|
if (lastUserMessageId)
|
|
898
903
|
body.reply_to_message_id = lastUserMessageId;
|
|
899
904
|
const r = await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
|
|
900
|
-
method: "POST",
|
|
905
|
+
method: "POST",
|
|
906
|
+
headers: { "Content-Type": "application/json" },
|
|
901
907
|
body: JSON.stringify(body),
|
|
902
908
|
});
|
|
903
909
|
if (!r.ok)
|
|
904
910
|
throw new Error(await r.text());
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
911
|
+
},
|
|
912
|
+
sms: async () => {
|
|
913
|
+
const smsClient = twilio(cfg.sms.accountSid, cfg.sms.authToken);
|
|
914
|
+
await smsClient.messages.create({ body: message, from: cfg.sms.from, to: cfg.sms.to });
|
|
915
|
+
},
|
|
916
|
+
email: async () => {
|
|
917
|
+
const email = cfg.email;
|
|
918
|
+
let transport;
|
|
919
|
+
if (email.refreshToken && email.clientId && email.clientSecret) {
|
|
920
|
+
transport = nodemailer.createTransport({
|
|
921
|
+
service: "gmail",
|
|
922
|
+
auth: {
|
|
923
|
+
type: "OAuth2",
|
|
924
|
+
user: email.connectedEmail ?? email.to,
|
|
925
|
+
clientId: email.clientId,
|
|
926
|
+
clientSecret: email.clientSecret,
|
|
927
|
+
refreshToken: email.refreshToken,
|
|
928
|
+
accessToken: email.accessToken,
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
else if (email.host && email.user && email.pass) {
|
|
933
|
+
transport = nodemailer.createTransport({
|
|
934
|
+
host: email.host,
|
|
935
|
+
port: email.port ?? 587,
|
|
936
|
+
secure: email.secure ?? false,
|
|
937
|
+
auth: { user: email.user, pass: email.pass },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
await transport.sendMail({
|
|
944
|
+
from: email.connectedEmail ?? email.user ?? email.to,
|
|
945
|
+
to: email.to,
|
|
946
|
+
subject: "Claude Notify",
|
|
947
|
+
text: message,
|
|
932
948
|
});
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
return;
|
|
936
|
-
await transport.sendMail({ from: email.connectedEmail ?? email.user ?? email.to,
|
|
937
|
-
to: email.to, subject: "Claude Notify", text: message });
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
// ntfy (built-in server — direct fanout, no external fetch)
|
|
941
|
-
if (!desktopOnlyMode) {
|
|
942
|
-
const ntfy = cfg.ntfy ?? {};
|
|
943
|
-
if (ntfy.enabled && ntfy.topic) {
|
|
944
|
-
await send("ntfy", async () => {
|
|
949
|
+
},
|
|
950
|
+
ntfy: async (_text, prio) => {
|
|
945
951
|
const priorityMap = { low: 2, normal: 3, high: 5 };
|
|
946
|
-
const tags =
|
|
947
|
-
const subs = ntfySubscribers.get(ntfy.topic)?.size ?? 0;
|
|
952
|
+
const tags = prio === "high" ? "rotating_light" : "bell";
|
|
953
|
+
const subs = ntfySubscribers.get(cfg.ntfy.topic)?.size ?? 0;
|
|
948
954
|
if (subs === 0)
|
|
949
|
-
throw new Error(`ntfy: no subscribers on topic '${ntfy.topic}'
|
|
950
|
-
ntfyFanout(ntfy.topic, message, "Claude Notify", priorityMap[
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
// Discord
|
|
955
|
-
if (!desktopOnlyMode) {
|
|
956
|
-
const dc = cfg.discord ?? {};
|
|
957
|
-
if (dc.enabled && dc.webhookUrl) {
|
|
958
|
-
await send("discord", async () => {
|
|
955
|
+
throw new Error(`ntfy: no subscribers on topic '${cfg.ntfy.topic}'`);
|
|
956
|
+
ntfyFanout(cfg.ntfy.topic, message, "Claude Notify", priorityMap[prio] ?? 3, tags);
|
|
957
|
+
},
|
|
958
|
+
discord: async (_text, prio) => {
|
|
959
959
|
const colorMap = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
|
|
960
|
-
const r = await fetch(
|
|
960
|
+
const r = await fetch(cfg.discord.webhookUrl, {
|
|
961
961
|
method: "POST",
|
|
962
962
|
headers: { "Content-Type": "application/json" },
|
|
963
963
|
body: JSON.stringify({
|
|
964
|
-
username:
|
|
964
|
+
username: cfg.discord.username ?? "Claude Notify",
|
|
965
965
|
embeds: [{
|
|
966
966
|
title: "Claude Notify",
|
|
967
967
|
description: message,
|
|
968
|
-
color: colorMap[
|
|
968
|
+
color: colorMap[prio] ?? colorMap.normal,
|
|
969
969
|
timestamp: new Date().toISOString(),
|
|
970
970
|
}],
|
|
971
971
|
}),
|
|
972
972
|
});
|
|
973
973
|
if (!r.ok)
|
|
974
974
|
throw new Error(`Discord ${r.status}: ${await r.text()}`);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
// Slack
|
|
979
|
-
if (!desktopOnlyMode) {
|
|
980
|
-
const sl = cfg.slack ?? {};
|
|
981
|
-
if (sl.enabled && sl.webhookUrl) {
|
|
982
|
-
await send("slack", async () => {
|
|
975
|
+
},
|
|
976
|
+
slack: async (_text, prio) => {
|
|
983
977
|
const emojiMap = { low: "ℹ️", normal: "🔔", high: "🚨" };
|
|
984
|
-
const emoji = emojiMap[
|
|
985
|
-
const r = await fetch(
|
|
978
|
+
const emoji = emojiMap[prio] ?? emojiMap.normal;
|
|
979
|
+
const r = await fetch(cfg.slack.webhookUrl, {
|
|
986
980
|
method: "POST",
|
|
987
981
|
headers: { "Content-Type": "application/json" },
|
|
988
982
|
body: JSON.stringify({
|
|
989
983
|
text: `${emoji} *Claude Notify*`,
|
|
990
984
|
blocks: [
|
|
991
985
|
{ type: "section", text: { type: "mrkdwn", text: `${emoji} *Claude Notify*\n${message}` } },
|
|
992
|
-
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${
|
|
986
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${prio}` }] },
|
|
993
987
|
],
|
|
994
988
|
}),
|
|
995
989
|
});
|
|
996
990
|
if (!r.ok)
|
|
997
991
|
throw new Error(`Slack ${r.status}: ${await r.text()}`);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
// Teams
|
|
1002
|
-
if (!desktopOnlyMode) {
|
|
1003
|
-
const tm = cfg.teams ?? {};
|
|
1004
|
-
if (tm.enabled && tm.webhookUrl) {
|
|
1005
|
-
await send("teams", async () => {
|
|
992
|
+
},
|
|
993
|
+
teams: async (_text, prio) => {
|
|
1006
994
|
const colorMap = { low: "Default", normal: "Accent", high: "Attention" };
|
|
1007
|
-
const r = await fetch(
|
|
995
|
+
const r = await fetch(cfg.teams.webhookUrl, {
|
|
1008
996
|
method: "POST",
|
|
1009
997
|
headers: { "Content-Type": "application/json" },
|
|
1010
998
|
body: JSON.stringify({
|
|
@@ -1014,11 +1002,12 @@ async function sendNotification(message, priority, client) {
|
|
|
1014
1002
|
contentUrl: null,
|
|
1015
1003
|
content: {
|
|
1016
1004
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
1017
|
-
type: "AdaptiveCard",
|
|
1005
|
+
type: "AdaptiveCard",
|
|
1006
|
+
version: "1.2",
|
|
1018
1007
|
body: [
|
|
1019
|
-
{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[
|
|
1008
|
+
{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[prio] ?? "Default" },
|
|
1020
1009
|
{ type: "TextBlock", text: message, wrap: true },
|
|
1021
|
-
{ type: "TextBlock", text: `Priority: ${
|
|
1010
|
+
{ type: "TextBlock", text: `Priority: ${prio}`, isSubtle: true, size: "Small" },
|
|
1022
1011
|
],
|
|
1023
1012
|
},
|
|
1024
1013
|
}],
|
|
@@ -1026,12 +1015,22 @@ async function sendNotification(message, priority, client) {
|
|
|
1026
1015
|
});
|
|
1027
1016
|
if (!r.ok)
|
|
1028
1017
|
throw new Error(`Teams ${r.status}: ${await r.text()}`);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
if (result.suppressedReason) {
|
|
1022
|
+
log("·", "notify", `suppressed (${result.suppressedReason})`, client);
|
|
1023
|
+
return result.suppressedReason;
|
|
1024
|
+
}
|
|
1025
|
+
for (const delivered of result.delivered) {
|
|
1026
|
+
log("→", delivered, message, client);
|
|
1027
|
+
}
|
|
1028
|
+
for (const err of result.errors) {
|
|
1029
|
+
log("→", "notify", `ERROR: ${err}`, client);
|
|
1031
1030
|
}
|
|
1032
1031
|
return [
|
|
1033
|
-
|
|
1034
|
-
errors.length ? `Errors: ${errors.join("; ")}` : null,
|
|
1032
|
+
result.delivered.length ? `Sent via: ${result.delivered.join(", ")}` : null,
|
|
1033
|
+
result.errors.length ? `Errors: ${result.errors.join("; ")}` : null,
|
|
1035
1034
|
].filter(Boolean).join(" | ") || "No channels delivered";
|
|
1036
1035
|
}
|
|
1037
1036
|
// ── OS idle-time (cross-platform) ─────────────────────────────────────────────
|
|
@@ -1169,8 +1168,11 @@ function writeInboxDrop(entry) {
|
|
|
1169
1168
|
const header = `# Unsolicited user message\n\n` +
|
|
1170
1169
|
`- Time: ${entry.ts}\n` +
|
|
1171
1170
|
(entry.tag ? `- Tag: @${entry.tag}\n` : "") +
|
|
1172
|
-
`- Origin: user (out-of-band)\n\n`;
|
|
1173
|
-
|
|
1171
|
+
`- Origin: ${entry.origin ?? "user"} (out-of-band)\n\n`;
|
|
1172
|
+
const replyHint = entry.origin === "slack"
|
|
1173
|
+
? `\n---\n↩ This arrived over the Slack bus — the user is WAITING in the channel. Reply there ASAP when done:\n\`\`\`\ncurl -s -X POST http://localhost:${PORT}/api/agent/slack/reply -H 'Content-Type: application/json' -d '{"text":"YOUR ANSWER","tag":"${entry.tag ?? ""}"}'\n\`\`\`\n`
|
|
1174
|
+
: "";
|
|
1175
|
+
writeFileSync(path, header + entry.text + "\n" + replyHint);
|
|
1174
1176
|
}
|
|
1175
1177
|
catch (err) {
|
|
1176
1178
|
log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1221,6 +1223,22 @@ function broadcastInbox(entry) {
|
|
|
1221
1223
|
}
|
|
1222
1224
|
return delivered;
|
|
1223
1225
|
}
|
|
1226
|
+
function ingestInboxEntry(entry, source) {
|
|
1227
|
+
const waiters = takeWaitersFor(entry.tag);
|
|
1228
|
+
if (waiters.length > 0) {
|
|
1229
|
+
for (const w of waiters) {
|
|
1230
|
+
clearTimeout(w.timer);
|
|
1231
|
+
w.resolve([entry]);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
inboxQueue.push(entry);
|
|
1236
|
+
writeInboxDrop(entry);
|
|
1237
|
+
}
|
|
1238
|
+
const sse = broadcastInbox(entry);
|
|
1239
|
+
log("·", "inbox", `${source}: ${entry.text} (sse=${sse}, waiters=${waiters.length})`, entry.tag);
|
|
1240
|
+
return { waiters: waiters.length, sse };
|
|
1241
|
+
}
|
|
1224
1242
|
// Test-only: inject a fake inbox entry exactly as the Telegram listener would.
|
|
1225
1243
|
// Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
|
|
1226
1244
|
// production run. Used by the test suite to drive wait_for_inbox wake-up and
|
|
@@ -1234,20 +1252,8 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
|
|
|
1234
1252
|
return;
|
|
1235
1253
|
}
|
|
1236
1254
|
const entry = { text, ts: new Date().toISOString(), tag };
|
|
1237
|
-
const
|
|
1238
|
-
|
|
1239
|
-
for (const w of waiters) {
|
|
1240
|
-
clearTimeout(w.timer);
|
|
1241
|
-
w.resolve([entry]);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
else {
|
|
1245
|
-
inboxQueue.push(entry);
|
|
1246
|
-
}
|
|
1247
|
-
const sse = broadcastInbox(entry);
|
|
1248
|
-
writeInboxDrop(entry);
|
|
1249
|
-
log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
|
|
1250
|
-
res.json({ injected: true, waiters: waiters.length, sse });
|
|
1255
|
+
const out = ingestInboxEntry(entry, "test-inject");
|
|
1256
|
+
res.json({ injected: true, waiters: out.waiters, sse: out.sse });
|
|
1251
1257
|
});
|
|
1252
1258
|
log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
|
|
1253
1259
|
}
|
|
@@ -1274,6 +1280,206 @@ app.get("/api/inbox/stream", (req, res) => {
|
|
|
1274
1280
|
inboxStreamClients.delete(client);
|
|
1275
1281
|
});
|
|
1276
1282
|
});
|
|
1283
|
+
// ── Non-MCP automation API ───────────────────────────────────────────────────
|
|
1284
|
+
// These endpoints let an agent script use plain HTTP (no MCP transport) for
|
|
1285
|
+
// notify + unsolicited inbox handling.
|
|
1286
|
+
app.post("/api/agent/notify", async (req, res) => {
|
|
1287
|
+
if (!requireAgentAuth(req, res))
|
|
1288
|
+
return;
|
|
1289
|
+
const message = String(req.body?.message ?? "").trim();
|
|
1290
|
+
const priority = String(req.body?.priority ?? "normal");
|
|
1291
|
+
if (!message) {
|
|
1292
|
+
res.status(400).json({ error: "message required" });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (!["low", "normal", "high"].includes(priority)) {
|
|
1296
|
+
res.status(400).json({ error: "invalid priority" });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
try {
|
|
1300
|
+
const out = await sendNotification(message, priority, "agent-http");
|
|
1301
|
+
res.json({ ok: true, result: out });
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
app.get("/api/agent/inbox/poll", (req, res) => {
|
|
1308
|
+
if (!requireAgentAuth(req, res))
|
|
1309
|
+
return;
|
|
1310
|
+
const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
|
|
1311
|
+
const messages = drainInboxFor(tag);
|
|
1312
|
+
res.json({ ok: true, messages });
|
|
1313
|
+
});
|
|
1314
|
+
app.get("/api/agent/inbox/wait", async (req, res) => {
|
|
1315
|
+
if (!requireAgentAuth(req, res))
|
|
1316
|
+
return;
|
|
1317
|
+
const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
|
|
1318
|
+
const timeoutSecondsRaw = parseInt(String(req.query.timeout_seconds ?? "50"), 10);
|
|
1319
|
+
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) ? Math.max(5, Math.min(55, timeoutSecondsRaw)) : 50;
|
|
1320
|
+
const queued = drainInboxFor(tag);
|
|
1321
|
+
if (queued.length > 0) {
|
|
1322
|
+
res.json({ ok: true, messages: queued, empty: false });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const token = randomUUID();
|
|
1326
|
+
const entries = await new Promise((resolve) => {
|
|
1327
|
+
const timer = setTimeout(() => {
|
|
1328
|
+
inboxWaiters.delete(token);
|
|
1329
|
+
resolve([]);
|
|
1330
|
+
}, timeoutSeconds * 1000);
|
|
1331
|
+
inboxWaiters.set(token, { resolve, timer, tag });
|
|
1332
|
+
});
|
|
1333
|
+
res.json({ ok: true, messages: entries, empty: entries.length === 0 });
|
|
1334
|
+
});
|
|
1335
|
+
app.post("/api/agent/inbox/inject", (req, res) => {
|
|
1336
|
+
if (!requireAgentAuth(req, res))
|
|
1337
|
+
return;
|
|
1338
|
+
const text = String(req.body?.text ?? "").trim();
|
|
1339
|
+
if (!text) {
|
|
1340
|
+
res.status(400).json({ error: "text required" });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1344
|
+
const entry = { text, ts: new Date().toISOString(), tag };
|
|
1345
|
+
const out = ingestInboxEntry(entry, "agent-inject");
|
|
1346
|
+
res.json({ ok: true, waiters: out.waiters, sse: out.sse });
|
|
1347
|
+
});
|
|
1348
|
+
app.post("/api/agent/slack/reply", (req, res) => {
|
|
1349
|
+
if (!requireAgentAuth(req, res))
|
|
1350
|
+
return;
|
|
1351
|
+
const text = String(req.body?.text ?? "").trim();
|
|
1352
|
+
if (!text) {
|
|
1353
|
+
res.status(400).json({ error: "text required" });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1357
|
+
slackPost(tag ? `[@${tag}] ${text}` : text);
|
|
1358
|
+
log("→", "slack:reply", text, tag);
|
|
1359
|
+
res.json({ ok: true });
|
|
1360
|
+
});
|
|
1361
|
+
// Interactive-session busy/idle, reported by that session's hooks (busy on
|
|
1362
|
+
// UserPromptSubmit/PreToolUse/PostToolUse, idle on Stop). Lets the bus tell the
|
|
1363
|
+
// user "Claude is busy" + a rough ETA when a request lands on a busy prompt.
|
|
1364
|
+
const sessionBusy = {};
|
|
1365
|
+
const busyDurations = [];
|
|
1366
|
+
function setSessionState(tag, busy) {
|
|
1367
|
+
const prev = sessionBusy[tag];
|
|
1368
|
+
if (busy) {
|
|
1369
|
+
if (!prev?.busy)
|
|
1370
|
+
sessionBusy[tag] = { busy: true, since: Date.now() };
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
if (prev?.busy) {
|
|
1374
|
+
busyDurations.push(Date.now() - prev.since);
|
|
1375
|
+
if (busyDurations.length > 20)
|
|
1376
|
+
busyDurations.shift();
|
|
1377
|
+
}
|
|
1378
|
+
sessionBusy[tag] = { busy: false, since: 0 };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
function busyEtaSecs() {
|
|
1382
|
+
if (!busyDurations.length)
|
|
1383
|
+
return null;
|
|
1384
|
+
return Math.round(busyDurations.reduce((a, b) => a + b, 0) / busyDurations.length / 1000);
|
|
1385
|
+
}
|
|
1386
|
+
function sessionBusyNote(tag) {
|
|
1387
|
+
const s = sessionBusy[tag];
|
|
1388
|
+
if (!s?.busy)
|
|
1389
|
+
return "";
|
|
1390
|
+
const secs = Math.round((Date.now() - s.since) / 1000);
|
|
1391
|
+
const eta = busyEtaSecs();
|
|
1392
|
+
return `🔧 Claude @${tag} is busy right now (working ${secs}s) — your request is queued and runs the moment the prompt is free.${eta ? ` Turns here usually finish in ~${eta}s.` : ""}`;
|
|
1393
|
+
}
|
|
1394
|
+
app.post("/api/session/state", (req, res) => {
|
|
1395
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1396
|
+
if (!tag) {
|
|
1397
|
+
res.status(400).json({ error: "tag required" });
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
setSessionState(tag, req.body?.busy === true || req.body?.busy === "true");
|
|
1401
|
+
res.json({ ok: true, busy: sessionBusy[tag]?.busy ?? false });
|
|
1402
|
+
});
|
|
1403
|
+
// Slack Events API (inbound). Requires configuring your Slack app with an
|
|
1404
|
+
// Event Request URL: POST /api/slack/events.
|
|
1405
|
+
app.post("/api/slack/events", (req, res) => {
|
|
1406
|
+
let body = req.body ?? {};
|
|
1407
|
+
if (body?.payload && typeof body.payload === "string") {
|
|
1408
|
+
try {
|
|
1409
|
+
body = JSON.parse(body.payload);
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
// keep original body if payload isn't valid JSON
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if ((!body || Object.keys(body).length === 0) && req.rawBody) {
|
|
1416
|
+
const raw = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
|
|
1417
|
+
try {
|
|
1418
|
+
body = JSON.parse(raw);
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
body = req.body ?? {};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
// Slack URL verification can arrive before full event wiring, and some
|
|
1425
|
+
// intermediaries/proxies send this as form data. Respond immediately when
|
|
1426
|
+
// a challenge value is present so the URL can be saved.
|
|
1427
|
+
const challenge = body?.challenge ?? (typeof req.query.challenge === "string" ? req.query.challenge : undefined);
|
|
1428
|
+
if (challenge) {
|
|
1429
|
+
log("·", "slack", "url_verification challenge received");
|
|
1430
|
+
res.status(200).json({ challenge });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (!verifySlackSignature(req)) {
|
|
1434
|
+
log("·", "slack", "rejected: bad signature");
|
|
1435
|
+
res.status(401).json({ error: "bad slack signature" });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const envelopeType = String(body?.type ?? "unknown");
|
|
1439
|
+
const event = body?.event ?? {};
|
|
1440
|
+
const eventType = String(event?.type ?? "none");
|
|
1441
|
+
const subtype = typeof event?.subtype === "string" ? event.subtype : "";
|
|
1442
|
+
const channel = String(event?.channel ?? body?.channel_id ?? "none");
|
|
1443
|
+
log("·", "slack", `event: envelope=${envelopeType}, type=${eventType}, subtype=${subtype || "none"}, channel=${channel}`);
|
|
1444
|
+
if (body.type !== "event_callback") {
|
|
1445
|
+
res.json({ ok: true, ignored: true, reason: `unsupported envelope type: ${envelopeType}` });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const ignoreReasons = [];
|
|
1449
|
+
if (event.type !== "message")
|
|
1450
|
+
ignoreReasons.push(`event.type=${eventType}`);
|
|
1451
|
+
if (subtype)
|
|
1452
|
+
ignoreReasons.push(`subtype=${subtype}`);
|
|
1453
|
+
if (event.bot_id)
|
|
1454
|
+
ignoreReasons.push("bot message");
|
|
1455
|
+
if (!event.text)
|
|
1456
|
+
ignoreReasons.push("missing text");
|
|
1457
|
+
if (event.type !== "message" || subtype || event.bot_id || !event.text) {
|
|
1458
|
+
const reason = ignoreReasons.join(", ") || "filtered";
|
|
1459
|
+
log("·", "slack", `ignored: ${reason}`);
|
|
1460
|
+
res.json({ ok: true, ignored: true, reason });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const parsed = parseTag(String(event.text));
|
|
1464
|
+
const candidate = [...pendingAsks.entries()].find(([, p]) => (parsed.tag ? p.tag === parsed.tag : true));
|
|
1465
|
+
if (candidate) {
|
|
1466
|
+
const [id, pending] = candidate;
|
|
1467
|
+
clearTimeout(pending.timer);
|
|
1468
|
+
pendingAsks.delete(id);
|
|
1469
|
+
log("←", "ask:reply", parsed.text, parsed.tag);
|
|
1470
|
+
pending.resolve(parsed.text);
|
|
1471
|
+
res.json({ ok: true, routed: "ask" });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const ts = event.ts ? new Date(Number(event.ts) * 1000).toISOString() : new Date().toISOString();
|
|
1475
|
+
const entry = {
|
|
1476
|
+
text: parsed.text,
|
|
1477
|
+
tag: parsed.tag,
|
|
1478
|
+
ts,
|
|
1479
|
+
};
|
|
1480
|
+
const out = ingestInboxEntry(entry, "slack");
|
|
1481
|
+
res.json({ ok: true, routed: "inbox", waiters: out.waiters, sse: out.sse });
|
|
1482
|
+
});
|
|
1277
1483
|
async function initTgOffset(token) {
|
|
1278
1484
|
const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
|
|
1279
1485
|
const json = await r.json();
|
|
@@ -1369,28 +1575,19 @@ async function startTelegramListener() {
|
|
|
1369
1575
|
writeInboxDrop(entry);
|
|
1370
1576
|
const liveSseCount = broadcastInbox(entry);
|
|
1371
1577
|
log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
// cheerfully claims "broadcast to 3 sessions" when 2 of them are
|
|
1375
|
-
// closed VS Code windows — which is exactly what prompted this fix.
|
|
1376
|
-
pruneDeadSessions();
|
|
1377
|
-
// Build an ack that names the active sessions the user's message
|
|
1378
|
-
// is being routed to. If the user tagged it and no session with
|
|
1379
|
-
// that tag is connected, tell them plainly so they don't sit
|
|
1380
|
-
// waiting for a reply that can't come.
|
|
1381
|
-
const targets = sessionsMatchingTag(tag);
|
|
1578
|
+
const sseCount = sseSubscribersForTag(tag);
|
|
1579
|
+
const anyoneListening = sseCount > 0 || waiters.length > 0;
|
|
1382
1580
|
let ackText;
|
|
1383
|
-
if (
|
|
1581
|
+
if (!anyoneListening && tag) {
|
|
1384
1582
|
ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
|
|
1385
1583
|
}
|
|
1386
|
-
else if (
|
|
1584
|
+
else if (!anyoneListening) {
|
|
1387
1585
|
ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
|
|
1388
1586
|
}
|
|
1389
1587
|
else {
|
|
1390
|
-
const names = targets.map(sessionDisplay).join(", ");
|
|
1391
1588
|
ackText = tag
|
|
1392
|
-
? `📬 Routed to
|
|
1393
|
-
: `📬 Broadcast to ${
|
|
1589
|
+
? `📬 Routed to @${tag}. Waiting for reply.`
|
|
1590
|
+
: `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`;
|
|
1394
1591
|
}
|
|
1395
1592
|
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
1396
1593
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
@@ -1416,6 +1613,158 @@ async function startTelegramListener() {
|
|
|
1416
1613
|
}
|
|
1417
1614
|
}
|
|
1418
1615
|
}
|
|
1616
|
+
// ── Slack inbound poller (always-on; folds slack-poll.sh → #16) ────────────────
|
|
1617
|
+
const SLACK_SECRETS_PATH = join(fileURLToPath(new URL("../../notify-secrets.json", import.meta.url)));
|
|
1618
|
+
function decodeB64Fields(obj) {
|
|
1619
|
+
if (Array.isArray(obj))
|
|
1620
|
+
return obj.map(decodeB64Fields);
|
|
1621
|
+
if (obj && typeof obj === "object") {
|
|
1622
|
+
const out = {};
|
|
1623
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1624
|
+
if (k.endsWith("_b64") && typeof v === "string")
|
|
1625
|
+
out[k.slice(0, -4)] = Buffer.from(v, "base64").toString("utf8");
|
|
1626
|
+
else
|
|
1627
|
+
out[k] = decodeB64Fields(v);
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
}
|
|
1631
|
+
return obj;
|
|
1632
|
+
}
|
|
1633
|
+
function loadSecrets() {
|
|
1634
|
+
if (!existsSync(SLACK_SECRETS_PATH))
|
|
1635
|
+
return {};
|
|
1636
|
+
try {
|
|
1637
|
+
return decodeB64Fields(JSON.parse(readFileSync(SLACK_SECRETS_PATH, "utf8")));
|
|
1638
|
+
}
|
|
1639
|
+
catch (err) {
|
|
1640
|
+
log("·", "secrets", `load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1641
|
+
return {};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
function slackCreds() {
|
|
1645
|
+
const s = loadSecrets().slack ?? {};
|
|
1646
|
+
const cfgSlack = loadConfig().slack ?? {};
|
|
1647
|
+
const token = (process.env.SLACK_BOT_TOKEN ?? s.botToken ?? "").trim() || undefined;
|
|
1648
|
+
const channel = (process.env.SLACK_CHANNEL_ID ?? s.channelId ?? "").trim() || undefined;
|
|
1649
|
+
const webhook = (s.webhookUrl ?? cfgSlack.webhookUrl ?? "").trim() || undefined;
|
|
1650
|
+
return { token, channel, webhook };
|
|
1651
|
+
}
|
|
1652
|
+
async function slackPost(text) {
|
|
1653
|
+
const { webhook } = slackCreds();
|
|
1654
|
+
if (!webhook)
|
|
1655
|
+
return;
|
|
1656
|
+
try {
|
|
1657
|
+
await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
|
|
1658
|
+
}
|
|
1659
|
+
catch { /* webhook post is best-effort */ }
|
|
1660
|
+
}
|
|
1661
|
+
function slackClientTags() {
|
|
1662
|
+
const tags = new Set();
|
|
1663
|
+
for (const sess of listActiveSessions())
|
|
1664
|
+
if (sess.tag)
|
|
1665
|
+
tags.add(sess.tag);
|
|
1666
|
+
for (const c of inboxStreamClients)
|
|
1667
|
+
if (c.tag)
|
|
1668
|
+
tags.add(c.tag);
|
|
1669
|
+
for (const [, w] of inboxWaiters)
|
|
1670
|
+
if (w.tag)
|
|
1671
|
+
tags.add(w.tag);
|
|
1672
|
+
return [...tags].sort();
|
|
1673
|
+
}
|
|
1674
|
+
function slackClientsNumbered() {
|
|
1675
|
+
const tags = slackClientTags();
|
|
1676
|
+
return tags.length ? tags.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none connected)";
|
|
1677
|
+
}
|
|
1678
|
+
function resolveSlackClient(handle) {
|
|
1679
|
+
const tags = slackClientTags();
|
|
1680
|
+
if (/^[0-9]+$/.test(handle))
|
|
1681
|
+
return tags[parseInt(handle, 10) - 1];
|
|
1682
|
+
return tags.find(t => t === handle);
|
|
1683
|
+
}
|
|
1684
|
+
async function handleSlackCommand(lc) {
|
|
1685
|
+
if (lc === "list clients" || lc === "clients" || lc === "list") {
|
|
1686
|
+
await slackPost(`Connected clients — reply @<name> or #<id>:\n${slackClientsNumbered()}`);
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
if (lc === "help" || lc === "commands" || lc === "?") {
|
|
1690
|
+
await slackPost("Commands: `clients`. Direct: `@<name> msg`, `#<id> msg`, or untagged → broadcast to all.");
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
let slackCursor = "";
|
|
1696
|
+
let slackConsecutiveErrors = 0;
|
|
1697
|
+
async function pollSlackOnce(token, channel) {
|
|
1698
|
+
const url = `https://slack.com/api/conversations.history?channel=${encodeURIComponent(channel)}&oldest=${encodeURIComponent(slackCursor)}&limit=50`;
|
|
1699
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
1700
|
+
const json = await r.json();
|
|
1701
|
+
if (!json.ok)
|
|
1702
|
+
throw new Error(`conversations.history: ${json.error ?? "unknown"}`);
|
|
1703
|
+
const all = json.messages ?? [];
|
|
1704
|
+
const human = all
|
|
1705
|
+
.filter(m => !m.subtype && !m.bot_id && !m.app_id && m.user && String(m.text ?? "").trim())
|
|
1706
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
1707
|
+
for (const m of human) {
|
|
1708
|
+
const text = String(m.text ?? "");
|
|
1709
|
+
const stripped = text.replace(/<@[A-Za-z0-9]+>/g, "");
|
|
1710
|
+
const lc = stripped.toLowerCase().trim();
|
|
1711
|
+
if (await handleSlackCommand(lc)) {
|
|
1712
|
+
log("←", "slack:cmd", lc);
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const clean = stripped.replace(/^\s+/, "");
|
|
1716
|
+
const routed = clean.match(/^[@#]([^\s]+)\s+([\s\S]*)$/);
|
|
1717
|
+
if (routed) {
|
|
1718
|
+
const handle = routed[1];
|
|
1719
|
+
const msg = routed[2];
|
|
1720
|
+
const tag = resolveSlackClient(handle);
|
|
1721
|
+
if (!tag) {
|
|
1722
|
+
await slackPost(`❌ Unknown client "${handle}". Connected:\n${slackClientsNumbered()}`);
|
|
1723
|
+
log("←", "slack", `unknown client: ${handle}`);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
ingestInboxEntry({ text: msg, ts: new Date().toISOString(), tag, origin: "slack" }, "slack");
|
|
1727
|
+
await slackPost(sessionBusyNote(tag) || "ack");
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
ingestInboxEntry({ text, ts: new Date().toISOString(), origin: "slack" }, "slack");
|
|
1731
|
+
await slackPost("ack");
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const newest = all.reduce((acc, m) => (Number(m.ts) > Number(acc || 0) ? String(m.ts) : acc), slackCursor);
|
|
1735
|
+
if (newest)
|
|
1736
|
+
slackCursor = newest;
|
|
1737
|
+
}
|
|
1738
|
+
async function startSlackListener() {
|
|
1739
|
+
const interval = (Number(process.env.SLACK_POLL_INTERVAL) || 2) * 1000;
|
|
1740
|
+
while (true) {
|
|
1741
|
+
try {
|
|
1742
|
+
const { token, channel } = slackCreds();
|
|
1743
|
+
if (!token || !channel) {
|
|
1744
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (!slackCursor) {
|
|
1748
|
+
slackCursor = String(Math.floor(Date.now() / 1000) - 300);
|
|
1749
|
+
log("·", "slack", `listener ready, channel=${channel}, backfill=300s (closes restart gap)`);
|
|
1750
|
+
}
|
|
1751
|
+
await pollSlackOnce(token, channel);
|
|
1752
|
+
if (slackConsecutiveErrors > 0) {
|
|
1753
|
+
log("·", "slack", `recovered after ${slackConsecutiveErrors} attempt(s)`);
|
|
1754
|
+
slackConsecutiveErrors = 0;
|
|
1755
|
+
}
|
|
1756
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1757
|
+
}
|
|
1758
|
+
catch (err) {
|
|
1759
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1760
|
+
if (!msg.includes("terminated") && !msg.includes("aborted"))
|
|
1761
|
+
log("·", "slack:error", msg);
|
|
1762
|
+
slackConsecutiveErrors++;
|
|
1763
|
+
const delay = Math.min(60_000, 2000 * Math.pow(2, Math.min(5, slackConsecutiveErrors - 1)));
|
|
1764
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1419
1768
|
app.get("/reply/:token", (req, res) => {
|
|
1420
1769
|
const pending = pendingAsks.get(req.params.token);
|
|
1421
1770
|
res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
|
|
@@ -1886,6 +2235,22 @@ function sessionsMatchingTag(tag) {
|
|
|
1886
2235
|
return listActiveSessions();
|
|
1887
2236
|
return listActiveSessions().filter(s => s.tag === tag);
|
|
1888
2237
|
}
|
|
2238
|
+
// Count live SSE subscribers on /api/inbox/stream that would receive a message
|
|
2239
|
+
// with the given tag. Stdio-bridge clients subscribe via SSE but don't always
|
|
2240
|
+
// appear in sessions[] (their /mcp initialize session can get reaped while the
|
|
2241
|
+
// SSE stream stays alive). Without counting them, the Telegram ack lies with
|
|
2242
|
+
// "no agents connected" even when a bridge is actively listening.
|
|
2243
|
+
function sseSubscribersForTag(tag) {
|
|
2244
|
+
let n = 0;
|
|
2245
|
+
for (const c of inboxStreamClients) {
|
|
2246
|
+
if (c.res.destroyed || c.res.writableEnded || !c.res.writable)
|
|
2247
|
+
continue;
|
|
2248
|
+
if (tag && c.tag !== tag)
|
|
2249
|
+
continue;
|
|
2250
|
+
n++;
|
|
2251
|
+
}
|
|
2252
|
+
return n;
|
|
2253
|
+
}
|
|
1889
2254
|
// Synchronous best-effort liveness check before we count sessions in an ack.
|
|
1890
2255
|
// The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
|
|
1891
2256
|
// the response stream of the last GET the client opened — if that stream is
|
|
@@ -1922,6 +2287,13 @@ function sessionDisplay(s) {
|
|
|
1922
2287
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1923
2288
|
}
|
|
1924
2289
|
app.all("/mcp", async (req, res) => {
|
|
2290
|
+
if (!ENABLE_MCP) {
|
|
2291
|
+
res.status(404).json({
|
|
2292
|
+
error: "mcp_disabled",
|
|
2293
|
+
message: "MCP transport is disabled. Set ENABLE_MCP=1 to enable /mcp.",
|
|
2294
|
+
});
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
1925
2297
|
console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
|
|
1926
2298
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1927
2299
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
@@ -2055,8 +2427,14 @@ function trackReconnect(clientId) {
|
|
|
2055
2427
|
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
2056
2428
|
const ip = getLocalIp();
|
|
2057
2429
|
console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
|
|
2058
|
-
|
|
2430
|
+
if (ENABLE_MCP) {
|
|
2431
|
+
console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
|
|
2432
|
+
}
|
|
2433
|
+
else {
|
|
2434
|
+
console.log(" MCP endpoint (remote) → disabled (set ENABLE_MCP=1 to enable)\n");
|
|
2435
|
+
}
|
|
2059
2436
|
startTelegramListener();
|
|
2437
|
+
startSlackListener();
|
|
2060
2438
|
open(`http://localhost:${PORT}`).catch(() => { });
|
|
2061
2439
|
});
|
|
2062
2440
|
// TCP-level keepalive on every incoming socket. Without this, a client that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
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
|
@@ -83,7 +83,7 @@ function populateForm() {
|
|
|
83
83
|
$("ntfy-enabled").checked = !!ntfy.enabled;
|
|
84
84
|
$("ntfy-topic").value = ntfy.topic ?? "";
|
|
85
85
|
const defaultUrl = `${location.protocol}//${location.hostname}:${location.port || (location.protocol === 'https:' ? 443 : 80)}`;
|
|
86
|
-
$("ntfy-server-url").value = ntfy.serverUrl || defaultUrl;
|
|
86
|
+
$("ntfy-server-url").value = (ntfy.serverUrl || defaultUrl).replace(/\/ntfy\/?$/, "");
|
|
87
87
|
|
|
88
88
|
// Discord
|
|
89
89
|
const dc = config.discord ?? {};
|
package/ui/public/help.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<div class="tabs">
|
|
41
41
|
<button class="tab tab-active" data-tab="claude-code">Claude Code (CLI)</button>
|
|
42
42
|
<button class="tab" data-tab="cursor">Cursor</button>
|
|
43
|
-
<button class="tab" data-tab="vscode">VS Code</button>
|
|
43
|
+
<button class="tab" data-tab="vscode">VS Code + Copilot</button>
|
|
44
44
|
<button class="tab" data-tab="claude-desktop">Claude Desktop</button>
|
|
45
45
|
<button class="tab" data-tab="windsurf">Windsurf</button>
|
|
46
46
|
<button class="tab" data-tab="zed">Zed</button>
|
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
}</code></pre>
|
|
126
126
|
</div>
|
|
127
127
|
<p class="hint">Or via Command Palette → <em>MCP: Add Server</em> → npm package → <code>omni-notify-mcp</code>.</p>
|
|
128
|
+
<p class="hint"><strong>Important for GitHub Copilot Chat/Agent:</strong> Copilot can miss server-pushed notifications. For reliable inbound user messages, the agent should loop on <code>wait_for_inbox(timeout_seconds=50)</code> and handle tool results immediately.</p>
|
|
128
129
|
</div>
|
|
129
130
|
|
|
130
131
|
<!-- Claude Desktop -->
|
|
@@ -230,16 +231,18 @@
|
|
|
230
231
|
|
|
231
232
|
<section class="help-section">
|
|
232
233
|
<h2>What the AI gets</h2>
|
|
233
|
-
<p>
|
|
234
|
+
<p>Eight tools, all server-configured. The agent never names a channel — it just calls <code>notify</code>/<code>ask</code> and your settings decide where it goes:</p>
|
|
234
235
|
<table class="tools-table">
|
|
235
236
|
<tr><td><code>notify</code></td><td>Send a message. <code>priority=low|normal|high</code> controls fan-out and bypasses DND when high.</td></tr>
|
|
236
237
|
<tr><td><code>ask</code></td><td>Send a question and <strong>wait for your reply</strong> (Telegram or email link).</td></tr>
|
|
237
238
|
<tr><td><code>poll</code></td><td>Drain unsolicited messages you sent the agent.</td></tr>
|
|
239
|
+
<tr><td><code>wait_for_inbox</code></td><td>Long-poll up to 50s and return immediately when a new inbound user message exists.</td></tr>
|
|
238
240
|
<tr><td><code>get_idle_seconds</code></td><td>Seconds since your last keyboard/mouse input.</td></tr>
|
|
239
241
|
<tr><td><code>get_idle_config</code></td><td>The server's idle-gating policy.</td></tr>
|
|
240
242
|
<tr><td><code>get_dnd_status</code></td><td>Whether Do Not Disturb is currently suppressing.</td></tr>
|
|
243
|
+
<tr><td><code>reply</code></td><td>Channel return-path for stdio clients that support pushed channel events.</td></tr>
|
|
241
244
|
</table>
|
|
242
|
-
<p class="hint">
|
|
245
|
+
<p class="hint">For Copilot, treat <code>wait_for_inbox</code> as the primary inbound path. Keep a tight loop and process every non-empty result before continuing long tasks.</p>
|
|
243
246
|
</section>
|
|
244
247
|
|
|
245
248
|
<section class="help-section">
|