omni-notify-mcp 1.1.7 → 1.1.8
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 +9 -0
- package/assets/screenshots/help-page.png +0 -0
- package/assets/screenshots/main-ui.png +0 -0
- package/dist/ui/server.js +178 -26
- package/package.json +1 -1
- package/ui/public/app.js +76 -0
- package/ui/public/index.html +6 -3
- package/ui/public/style.css +28 -0
package/README.md
CHANGED
|
@@ -106,6 +106,15 @@ Run multiple agents against the same notify server (e.g. one Claude session in `
|
|
|
106
106
|
- `priority='high'` always punches through.
|
|
107
107
|
- Agents can pre-flight with `get_dnd_status` to skip the round-trip when DND is on.
|
|
108
108
|
|
|
109
|
+
### Heartbeat-drain (stay responsive during long work)
|
|
110
|
+
Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets any pending user inbox messages piggy-backed on the response. The server-side MCP `instructions` tell agents to call `get_idle_seconds` every 15-30 seconds during long operations so a user ping from Telegram lands within 30 seconds even if the agent hasn't called `notify` in hours. When an inbox message lands, the agent is required to fire a terse `busy-ack` back via `notify` so the user knows they were heard — even if the full response comes later.
|
|
111
|
+
|
|
112
|
+
### Multi-session broadcast
|
|
113
|
+
When multiple agents connect to the same server (e.g. one Claude per repo), every untagged user message is broadcast to all of them. Each agent replies with its session id, the user picks who they want to address, then targets follow-ups with `@<tag>`. The Telegram ack names the sessions the message was routed to.
|
|
114
|
+
|
|
115
|
+
### Reconnect resilience
|
|
116
|
+
The server returns HTTP 404 on requests with a stale `mcp-session-id` (per the MCP Streamable HTTP spec), so a client that wakes up after a server restart automatically re-initializes on its next tool call instead of staying stuck with a dead session. Idle sessions are reaped after 10 minutes of inactivity so the session list and clients pill bar stay honest.
|
|
117
|
+
|
|
109
118
|
### Idle gating (anti-buzz)
|
|
110
119
|
The server publishes a policy `{ enabled, thresholdSeconds }`. Agents are **instructed** (via the MCP `instructions` field, surfaced to every connecting client) to call `get_idle_seconds` first, and **skip** sending a notification if you're actively at the keyboard. They can already see what they'd send. Only fire when you've stepped away. `priority='high'` always fires.
|
|
111
120
|
|
|
Binary file
|
|
Binary file
|
package/dist/ui/server.js
CHANGED
|
@@ -551,6 +551,17 @@ function log(direction, channel, text, client) {
|
|
|
551
551
|
catch { }
|
|
552
552
|
}
|
|
553
553
|
}
|
|
554
|
+
app.get("/api/sessions", (_req, res) => {
|
|
555
|
+
const list = listActiveSessions().map(s => ({
|
|
556
|
+
clientId: s.clientId,
|
|
557
|
+
tag: s.tag,
|
|
558
|
+
clientName: s.clientName,
|
|
559
|
+
clientVersion: s.clientVersion,
|
|
560
|
+
host: s.host,
|
|
561
|
+
connectedAt: s.connectedAt,
|
|
562
|
+
}));
|
|
563
|
+
res.json({ sessions: list });
|
|
564
|
+
});
|
|
554
565
|
app.get("/api/logs", (req, res) => {
|
|
555
566
|
res.setHeader("Content-Type", "text/event-stream");
|
|
556
567
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -578,8 +589,11 @@ async function sendNotification(message, priority, client) {
|
|
|
578
589
|
// they may have multiple agents running and this is a cheap local signal
|
|
579
590
|
// that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
|
|
580
591
|
// priority=high always bypasses idle entirely.
|
|
592
|
+
// Conversation bypass: if the user just messaged us from Telegram (within
|
|
593
|
+
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
594
|
+
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
581
595
|
let desktopOnlyMode = false;
|
|
582
|
-
if (priority !== "high" && cfg.idle?.enabled !== false) {
|
|
596
|
+
if (priority !== "high" && !inTelegramConvo && cfg.idle?.enabled !== false) {
|
|
583
597
|
const idleSecs = getOsIdleSeconds();
|
|
584
598
|
const threshold = cfg.idle?.thresholdSeconds ?? 120;
|
|
585
599
|
const userIsActive = idleSecs >= 0 && idleSecs < threshold;
|
|
@@ -594,6 +608,9 @@ async function sendNotification(message, priority, client) {
|
|
|
594
608
|
}
|
|
595
609
|
}
|
|
596
610
|
}
|
|
611
|
+
else if (priority !== "high" && inTelegramConvo) {
|
|
612
|
+
log("·", "idle", `bypassed (telegram convo active)`, client);
|
|
613
|
+
}
|
|
597
614
|
const send = async (name, fn) => {
|
|
598
615
|
try {
|
|
599
616
|
await fn();
|
|
@@ -732,6 +749,13 @@ const pendingAsks = new Map();
|
|
|
732
749
|
const inboxQueue = [];
|
|
733
750
|
let tgPollOffset = -1;
|
|
734
751
|
let lastUserMessageId;
|
|
752
|
+
// When the user pings us from Telegram, bypass idle-gating on outbound
|
|
753
|
+
// notifs for a while — clearly they want a Telegram reply back, so we
|
|
754
|
+
// shouldn't gate remote channels just because they're at the keyboard
|
|
755
|
+
// typing. TTL is short so normal idle-gating resumes once the conversation
|
|
756
|
+
// goes quiet.
|
|
757
|
+
let lastTelegramInboundAt = 0;
|
|
758
|
+
const TELEGRAM_CONVO_TTL_MS = 5 * 60 * 1000;
|
|
735
759
|
// Session tagging: a session may declare a tag (e.g. "alphawave") when it
|
|
736
760
|
// connects to /mcp?tag=alphawave. Telegram messages starting with "@<tag>"
|
|
737
761
|
// are routed only to sessions with that exact tag (tag prefix stripped).
|
|
@@ -844,6 +868,7 @@ async function startTelegramListener() {
|
|
|
844
868
|
if (msg?.chat?.id?.toString() === chatId && msg.text) {
|
|
845
869
|
log("←", "telegram", msg.text);
|
|
846
870
|
lastUserMessageId = msg.message_id;
|
|
871
|
+
lastTelegramInboundAt = Date.now();
|
|
847
872
|
const { tag, text } = parseTag(msg.text);
|
|
848
873
|
// Match an outstanding ask first. If the message is tagged, only
|
|
849
874
|
// route to a pending ask from that same session — otherwise fall
|
|
@@ -863,13 +888,24 @@ async function startTelegramListener() {
|
|
|
863
888
|
inboxQueue.push(entry);
|
|
864
889
|
broadcastInbox(entry);
|
|
865
890
|
log("·", "inbox", text, tag);
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
891
|
+
// Build an ack that names the active sessions the user's message
|
|
892
|
+
// is being routed to. If the user tagged it and no session with
|
|
893
|
+
// that tag is connected, tell them plainly so they don't sit
|
|
894
|
+
// waiting for a reply that can't come.
|
|
895
|
+
const targets = sessionsMatchingTag(tag);
|
|
896
|
+
let ackText;
|
|
897
|
+
if (tag && targets.length === 0) {
|
|
898
|
+
ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
|
|
899
|
+
}
|
|
900
|
+
else if (targets.length === 0) {
|
|
901
|
+
ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const names = targets.map(sessionDisplay).join(", ");
|
|
905
|
+
ackText = tag
|
|
906
|
+
? `📬 Routed to ${names}. Waiting for them to reply.`
|
|
907
|
+
: `📬 Broadcast to ${targets.length} session(s): ${names}. Each should reply with its identity — respond to the one you want.`;
|
|
908
|
+
}
|
|
873
909
|
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
874
910
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
875
911
|
body: JSON.stringify({
|
|
@@ -933,6 +969,18 @@ function drainInboxFor(tag) {
|
|
|
933
969
|
}
|
|
934
970
|
return taken;
|
|
935
971
|
}
|
|
972
|
+
// Appends an inbox block to any tool's text payload when messages are pending
|
|
973
|
+
// for the given session. Lets cheap read tools (get_idle_seconds, get_dnd_status)
|
|
974
|
+
// double as inbox drains, so a busy agent calling them as a keepalive still
|
|
975
|
+
// sees user messages even when it hasn't called notify/poll in a while.
|
|
976
|
+
function appendInbox(baseText, sessionTag, clientId) {
|
|
977
|
+
const messages = drainInboxFor(sessionTag);
|
|
978
|
+
if (messages.length === 0)
|
|
979
|
+
return baseText;
|
|
980
|
+
log("·", "poll", `${messages.length} message(s) drained via heartbeat`, clientId);
|
|
981
|
+
const inbox = messages.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
982
|
+
return `${baseText}\n\n⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${inbox}`;
|
|
983
|
+
}
|
|
936
984
|
const MCP_INSTRUCTIONS = `
|
|
937
985
|
This server delivers notifications to the user through whatever channels the
|
|
938
986
|
user has configured on the server side. The client never needs to know which
|
|
@@ -963,13 +1011,40 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
963
1011
|
user has already configured. Say 'notif' or 'notification' instead.
|
|
964
1012
|
|
|
965
1013
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
966
|
-
the 'notify' response, via 'poll',
|
|
967
|
-
to them THROUGH 'notify' so the reply
|
|
968
|
-
your chat output.
|
|
1014
|
+
the 'notify' response, via 'poll', via 'get_idle_seconds' piggy-back, or
|
|
1015
|
+
via the /api/inbox/stream SSE), reply to them THROUGH 'notify' so the reply
|
|
1016
|
+
actually reaches them — not just in your chat output. Multiple agents may
|
|
1017
|
+
be connected simultaneously — the server broadcasts every untagged inbox
|
|
1018
|
+
message to all of them, so the user can see who is listening. Your reply
|
|
1019
|
+
MUST identify which session you are (start with your own tag or client id,
|
|
1020
|
+
e.g. "[@alphawave]" or "[sess-abcd]") and give a brief status so the user
|
|
1021
|
+
can pick whom to respond to. If the user tagged their message (@<tag>),
|
|
1022
|
+
only the session with that tag should reply. If you are untagged and
|
|
1023
|
+
another session with the same project/workdir is already tagged, let the
|
|
1024
|
+
tagged one reply.
|
|
969
1025
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1026
|
+
5a. BUSY-ACK RULE (hard requirement): The moment you notice a user inbox
|
|
1027
|
+
message — whether via notify response, heartbeat piggy-back, or poll —
|
|
1028
|
+
REPLY IMMEDIATELY via 'notify' with a terse status, EVEN IF YOU CAN'T
|
|
1029
|
+
ACT ON THE REQUEST YET. Do not finish the current tool/task first.
|
|
1030
|
+
Drop what you're doing long enough to fire one 'notify' call, THEN
|
|
1031
|
+
resume. Format: "[<your-session-id>] busy on <current-task>; will
|
|
1032
|
+
respond at <milestone>". The user needs to know (a) you heard them,
|
|
1033
|
+
(b) you're not the stuck one, (c) when to expect a real answer. An
|
|
1034
|
+
inbox message that gets a delayed reply is worse than no reply — it
|
|
1035
|
+
wastes the user's time waiting on silence.
|
|
1036
|
+
|
|
1037
|
+
6. The 'get_idle_seconds', 'get_idle_config', and 'get_dnd_status' tools are
|
|
1038
|
+
informational reads, but they ALSO drain pending inbox messages. Use them
|
|
1039
|
+
as a cheap heartbeat during long work: call 'get_idle_seconds' EVERY 15-
|
|
1040
|
+
30 SECONDS while a long task runs (loop iteration, backtest, scan, build,
|
|
1041
|
+
etc.). If the user sent you a message while you were busy, it comes back
|
|
1042
|
+
piggy-backed on the response — you don't need to separately call 'poll'.
|
|
1043
|
+
This is a lightweight local read (no network, no DND or channel routing),
|
|
1044
|
+
so the cost is near zero. Without this heartbeat pattern, a busy agent is
|
|
1045
|
+
deaf to the user until its next 'notify' call — which may be minutes or
|
|
1046
|
+
hours away during long work. Treat 'get_idle_seconds' as the "check for
|
|
1047
|
+
user input" primitive, not an idle-gate check.
|
|
973
1048
|
|
|
974
1049
|
7. If your tool call fails with "MCP server not connected" / "transport
|
|
975
1050
|
closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
|
|
@@ -1120,23 +1195,25 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
1120
1195
|
};
|
|
1121
1196
|
});
|
|
1122
1197
|
server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
|
|
1123
|
-
"Call this
|
|
1124
|
-
"
|
|
1125
|
-
"
|
|
1198
|
+
"Call this periodically during long work as a cheap heartbeat — the server " +
|
|
1199
|
+
"will piggy-back any pending inbox messages in the response, so you stay " +
|
|
1200
|
+
"responsive to the user without having to call poll. Returns -1 if idle " +
|
|
1201
|
+
"detection is unsupported on this platform — in that case, proceed without " +
|
|
1202
|
+
"gating (fail-open).", {}, async () => {
|
|
1126
1203
|
const secs = getOsIdleSeconds();
|
|
1127
|
-
return { content: [{ type: "text", text: String(secs) }] };
|
|
1204
|
+
return { content: [{ type: "text", text: appendInbox(String(secs), sessionTag, clientId) }] };
|
|
1128
1205
|
});
|
|
1129
1206
|
server.tool("get_idle_config", "Returns the server's idle gating policy: { enabled, thresholdSeconds, alwaysDesktopWhenActive }. " +
|
|
1130
1207
|
"Informational only — the server gates internally on every notify call. " +
|
|
1131
|
-
"
|
|
1208
|
+
"Also drains pending inbox messages — safe to use as a heartbeat.", {}, async () => {
|
|
1132
1209
|
const cfg = loadConfig();
|
|
1133
1210
|
const idle = cfg.idle ?? { enabled: true, thresholdSeconds: 120, alwaysDesktopWhenActive: true };
|
|
1134
|
-
return { content: [{ type: "text", text: JSON.stringify(idle) }] };
|
|
1211
|
+
return { content: [{ type: "text", text: appendInbox(JSON.stringify(idle), sessionTag, clientId) }] };
|
|
1135
1212
|
});
|
|
1136
1213
|
server.tool("get_dnd_status", "Returns the current DND state: " +
|
|
1137
1214
|
"{ active: boolean, reason: 'manual' | 'schedule' | 'off' }. " +
|
|
1138
1215
|
"When active, the server will suppress delivery for priority < high. " +
|
|
1139
|
-
"
|
|
1216
|
+
"Also drains pending inbox messages — safe to use as a heartbeat.", {}, async () => {
|
|
1140
1217
|
const cfg = loadConfig();
|
|
1141
1218
|
const active = isDndActive(cfg);
|
|
1142
1219
|
let reason = "off";
|
|
@@ -1144,31 +1221,106 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
1144
1221
|
reason = cfg.dnd?.enabled ? "manual" : "schedule";
|
|
1145
1222
|
}
|
|
1146
1223
|
return {
|
|
1147
|
-
content: [{ type: "text", text: JSON.stringify({ active, reason }) }],
|
|
1224
|
+
content: [{ type: "text", text: appendInbox(JSON.stringify({ active, reason }), sessionTag, clientId) }],
|
|
1148
1225
|
};
|
|
1149
1226
|
});
|
|
1150
1227
|
return server;
|
|
1151
1228
|
}
|
|
1152
1229
|
const httpTransports = {};
|
|
1230
|
+
const sessions = {};
|
|
1231
|
+
// Reap sessions that haven't made any request in a while. Keeps the sessions
|
|
1232
|
+
// list and pills bar accurate even when clients vanish without closing their
|
|
1233
|
+
// transport (VS Code window closed, laptop lid shut, network died). On next
|
|
1234
|
+
// reconnect the client gets a 404 and reinitializes cleanly.
|
|
1235
|
+
const SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
1236
|
+
setInterval(() => {
|
|
1237
|
+
const now = Date.now();
|
|
1238
|
+
for (const [sessionId, meta] of Object.entries(sessions)) {
|
|
1239
|
+
if (now - meta.lastSeen > SESSION_IDLE_TIMEOUT_MS) {
|
|
1240
|
+
log("·", "session", `reaped idle session ${meta.clientId} (last seen ${Math.round((now - meta.lastSeen) / 1000)}s ago)`);
|
|
1241
|
+
try {
|
|
1242
|
+
httpTransports[sessionId]?.close();
|
|
1243
|
+
}
|
|
1244
|
+
catch { /* ignore */ }
|
|
1245
|
+
delete httpTransports[sessionId];
|
|
1246
|
+
delete sessions[sessionId];
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}, 60_000);
|
|
1250
|
+
function listActiveSessions() {
|
|
1251
|
+
return Object.values(sessions);
|
|
1252
|
+
}
|
|
1253
|
+
function sessionsMatchingTag(tag) {
|
|
1254
|
+
if (!tag)
|
|
1255
|
+
return listActiveSessions();
|
|
1256
|
+
return listActiveSessions().filter(s => s.tag === tag);
|
|
1257
|
+
}
|
|
1258
|
+
function sessionDisplay(s) {
|
|
1259
|
+
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1260
|
+
}
|
|
1153
1261
|
app.all("/mcp", async (req, res) => {
|
|
1154
1262
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1155
1263
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
1156
1264
|
await httpTransports[existingSessionId].handleRequest(req, res, req.body);
|
|
1265
|
+
// Lazy-populate clientInfo after initialize lands on an existing session.
|
|
1266
|
+
const meta = sessions[existingSessionId];
|
|
1267
|
+
if (meta)
|
|
1268
|
+
meta.lastSeen = Date.now();
|
|
1269
|
+
const mcpServer = httpTransports[existingSessionId].__mcpServer;
|
|
1270
|
+
if (meta && !meta.clientName && mcpServer?.getClientVersion) {
|
|
1271
|
+
try {
|
|
1272
|
+
const info = mcpServer.getClientVersion();
|
|
1273
|
+
if (info) {
|
|
1274
|
+
meta.clientName = info.name;
|
|
1275
|
+
meta.clientVersion = info.version;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
catch { /* ignore */ }
|
|
1279
|
+
}
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// Spec-compliant: if the client presents a session ID we don't know about
|
|
1283
|
+
// (server was restarted, or the transport was closed), return 404 so the
|
|
1284
|
+
// client knows to re-initialize. The previous silent-new-session behavior
|
|
1285
|
+
// left idle clients stuck with stale session bookkeeping until the VS Code
|
|
1286
|
+
// window was manually reloaded.
|
|
1287
|
+
if (existingSessionId) {
|
|
1288
|
+
res.status(404).json({
|
|
1289
|
+
jsonrpc: "2.0",
|
|
1290
|
+
error: { code: -32000, message: "Session not found — reinitialize" },
|
|
1291
|
+
id: null,
|
|
1292
|
+
});
|
|
1157
1293
|
return;
|
|
1158
1294
|
}
|
|
1159
1295
|
const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
|
|
1160
1296
|
const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
|
|
1161
1297
|
const newSessionId = randomUUID();
|
|
1162
|
-
const
|
|
1298
|
+
const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
|
|
1299
|
+
const port = req.socket.remotePort;
|
|
1300
|
+
// Build a distinguishable client id: tag wins if set; otherwise use host+port
|
|
1301
|
+
// so two untagged sessions from the same machine are still distinguishable.
|
|
1302
|
+
const clientId = sessionTag
|
|
1303
|
+
?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
|
|
1163
1304
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
|
|
1164
1305
|
transport.onclose = () => {
|
|
1165
|
-
if (transport.sessionId)
|
|
1306
|
+
if (transport.sessionId) {
|
|
1166
1307
|
delete httpTransports[transport.sessionId];
|
|
1308
|
+
delete sessions[transport.sessionId];
|
|
1309
|
+
}
|
|
1167
1310
|
};
|
|
1168
|
-
|
|
1311
|
+
const mcpServer = createMcpServer(clientId, sessionTag);
|
|
1312
|
+
await mcpServer.connect(transport);
|
|
1313
|
+
// Stash the underlying MCP server on the transport so subsequent requests
|
|
1314
|
+
// can grab clientInfo once the initialize handshake completes.
|
|
1315
|
+
transport.__mcpServer = mcpServer.server ?? mcpServer;
|
|
1169
1316
|
await transport.handleRequest(req, res, req.body);
|
|
1170
|
-
if (transport.sessionId)
|
|
1317
|
+
if (transport.sessionId) {
|
|
1171
1318
|
httpTransports[transport.sessionId] = transport;
|
|
1319
|
+
const now = Date.now();
|
|
1320
|
+
sessions[transport.sessionId] = {
|
|
1321
|
+
clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1172
1324
|
});
|
|
1173
1325
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
1174
1326
|
app.listen(PORT, "0.0.0.0", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
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
|
@@ -206,6 +206,20 @@ async function saveEmail() {
|
|
|
206
206
|
clearDirty("email");
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// Standalone enable-toggle handlers: persist immediately without requiring a
|
|
210
|
+
// Save-button click. Credentials still need the explicit Save flow, but the
|
|
211
|
+
// on/off switch auto-persists so users aren't left wondering why their
|
|
212
|
+
// toggle "snapped back" after a reload.
|
|
213
|
+
async function toggleTelegramEnabled() {
|
|
214
|
+
await patch({ telegram: { enabled: $("telegram-enabled").checked } });
|
|
215
|
+
}
|
|
216
|
+
async function toggleEmailEnabled() {
|
|
217
|
+
await patch({ email: { enabled: $("email-enabled").checked } });
|
|
218
|
+
}
|
|
219
|
+
async function toggleSmsEnabled() {
|
|
220
|
+
await patch({ sms: { enabled: $("sms-enabled").checked } });
|
|
221
|
+
}
|
|
222
|
+
|
|
209
223
|
async function saveTelegram() {
|
|
210
224
|
await patch({
|
|
211
225
|
telegram: {
|
|
@@ -552,11 +566,28 @@ function parseLogEntry(raw) {
|
|
|
552
566
|
return { ts: m[1], client: m[2] || null, dir: m[3], channel: m[4], msg: m[5] };
|
|
553
567
|
}
|
|
554
568
|
|
|
569
|
+
let logFilterClient = "";
|
|
570
|
+
|
|
571
|
+
function selectLogFilter(clientId) {
|
|
572
|
+
logFilterClient = clientId;
|
|
573
|
+
document.querySelectorAll("#session-pills .pill").forEach(p => {
|
|
574
|
+
p.classList.toggle("pill-active", (p.dataset.client || "") === clientId);
|
|
575
|
+
});
|
|
576
|
+
// Re-apply hidden class to all entries based on the new filter.
|
|
577
|
+
document.querySelectorAll("#log-panel .log-entry").forEach(el => {
|
|
578
|
+
const c = el.dataset.client || "";
|
|
579
|
+
el.style.display = (!clientId || c === clientId || (!c && clientId === "")) ? "" : "none";
|
|
580
|
+
});
|
|
581
|
+
const panel = $("log-panel");
|
|
582
|
+
panel.scrollTop = panel.scrollHeight;
|
|
583
|
+
}
|
|
584
|
+
|
|
555
585
|
function renderLogEntry(raw) {
|
|
556
586
|
const panel = $("log-panel");
|
|
557
587
|
const p = parseLogEntry(raw);
|
|
558
588
|
const el = document.createElement("div");
|
|
559
589
|
el.className = "log-entry";
|
|
590
|
+
el.dataset.client = (p && p.client) ? p.client : "";
|
|
560
591
|
|
|
561
592
|
if (p) {
|
|
562
593
|
const ts = new Date(p.ts).toLocaleTimeString([], { hour12: false });
|
|
@@ -574,11 +605,56 @@ function renderLogEntry(raw) {
|
|
|
574
605
|
el.innerHTML = `<span class="log-msg">${raw.replace(/</g,"<")}</span>`;
|
|
575
606
|
}
|
|
576
607
|
|
|
608
|
+
if (logFilterClient && el.dataset.client !== logFilterClient) {
|
|
609
|
+
el.style.display = "none";
|
|
610
|
+
}
|
|
577
611
|
const atBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 20;
|
|
578
612
|
panel.appendChild(el);
|
|
579
613
|
if (atBottom) panel.scrollTop = panel.scrollHeight;
|
|
580
614
|
}
|
|
581
615
|
|
|
616
|
+
async function refreshSessions() {
|
|
617
|
+
try {
|
|
618
|
+
const res = await fetch("/api/sessions");
|
|
619
|
+
if (!res.ok) return;
|
|
620
|
+
const { sessions } = await res.json();
|
|
621
|
+
const bar = $("session-pills");
|
|
622
|
+
// Build a set of desired pills (keyed by clientId). Keep the "All" pill.
|
|
623
|
+
const existing = new Map();
|
|
624
|
+
bar.querySelectorAll(".pill").forEach(p => existing.set(p.dataset.client || "", p));
|
|
625
|
+
const desired = new Set([""]);
|
|
626
|
+
for (const s of sessions) desired.add(s.clientId);
|
|
627
|
+
// Remove pills for disconnected sessions.
|
|
628
|
+
for (const [id, el] of existing) {
|
|
629
|
+
if (!desired.has(id)) el.remove();
|
|
630
|
+
}
|
|
631
|
+
// Add pills for new sessions.
|
|
632
|
+
for (const s of sessions) {
|
|
633
|
+
if (!existing.has(s.clientId)) {
|
|
634
|
+
const btn = document.createElement("button");
|
|
635
|
+
btn.className = "pill";
|
|
636
|
+
btn.dataset.client = s.clientId;
|
|
637
|
+
btn.textContent = s.tag ? `@${s.tag}` : s.clientId;
|
|
638
|
+
btn.title = [s.clientName, s.host].filter(Boolean).join(" · ");
|
|
639
|
+
btn.onclick = () => selectLogFilter(s.clientId);
|
|
640
|
+
bar.appendChild(btn);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// If the currently-selected client disconnected, fall back to "All".
|
|
644
|
+
if (logFilterClient && !desired.has(logFilterClient)) {
|
|
645
|
+
selectLogFilter("");
|
|
646
|
+
} else {
|
|
647
|
+
// Re-sync active state (in case pills were re-added).
|
|
648
|
+
bar.querySelectorAll(".pill").forEach(p => {
|
|
649
|
+
p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
} catch { /* ignore */ }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
setInterval(refreshSessions, 3000);
|
|
656
|
+
refreshSessions();
|
|
657
|
+
|
|
582
658
|
function clearLog() {
|
|
583
659
|
$("log-panel").innerHTML = "";
|
|
584
660
|
}
|
package/ui/public/index.html
CHANGED
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
<div id="gmail-connected-state" class="hidden">
|
|
88
88
|
<div class="actions">
|
|
89
89
|
<label class="toggle-wrap">
|
|
90
|
-
<input type="checkbox" id="email-enabled"
|
|
90
|
+
<input type="checkbox" id="email-enabled" onchange="toggleEmailEnabled()">
|
|
91
91
|
<span class="toggle"></span>
|
|
92
92
|
</label>
|
|
93
93
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
<div class="card-body">
|
|
135
135
|
<div class="actions">
|
|
136
136
|
<label class="toggle-wrap">
|
|
137
|
-
<input type="checkbox" id="telegram-enabled"
|
|
137
|
+
<input type="checkbox" id="telegram-enabled" onchange="toggleTelegramEnabled()">
|
|
138
138
|
<span class="toggle"></span>
|
|
139
139
|
</label>
|
|
140
140
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
<div class="card-body">
|
|
176
176
|
<div class="actions">
|
|
177
177
|
<label class="toggle-wrap">
|
|
178
|
-
<input type="checkbox" id="sms-enabled"
|
|
178
|
+
<input type="checkbox" id="sms-enabled" onchange="toggleSmsEnabled()">
|
|
179
179
|
<span class="toggle"></span>
|
|
180
180
|
</label>
|
|
181
181
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -303,6 +303,9 @@
|
|
|
303
303
|
<span class="dot dot-ok" id="log-dot" style="display:inline-block"></span>
|
|
304
304
|
Activity Log
|
|
305
305
|
</span>
|
|
306
|
+
<div class="session-pills" id="session-pills">
|
|
307
|
+
<button class="pill pill-active" data-client="" onclick="selectLogFilter('')">All</button>
|
|
308
|
+
</div>
|
|
306
309
|
<button class="btn btn-sm btn-ghost" onclick="clearLog()">Clear</button>
|
|
307
310
|
</div>
|
|
308
311
|
<div class="log-panel" id="log-panel"></div>
|
package/ui/public/style.css
CHANGED
|
@@ -204,6 +204,34 @@ main {
|
|
|
204
204
|
letter-spacing: .5px;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
.session-pills {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: 6px;
|
|
211
|
+
flex: 1;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
flex-wrap: wrap;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
}
|
|
216
|
+
.pill {
|
|
217
|
+
font: inherit;
|
|
218
|
+
font-size: 11px;
|
|
219
|
+
padding: 3px 10px;
|
|
220
|
+
border-radius: 12px;
|
|
221
|
+
border: 1px solid var(--border);
|
|
222
|
+
background: transparent;
|
|
223
|
+
color: var(--text-2);
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
transition: background .15s, color .15s, border-color .15s;
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
}
|
|
228
|
+
.pill:hover { border-color: var(--accent); color: var(--text); }
|
|
229
|
+
.pill-active {
|
|
230
|
+
background: var(--accent);
|
|
231
|
+
border-color: var(--accent);
|
|
232
|
+
color: #fff;
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
208
236
|
|
|
209
237
|
.card {
|