const crypto = require("crypto"); const { db } = require("./db"); const { publishWebEvent } = require("./web-events"); const { getOverlayModuleType } = require("./overlay-modules"); const recentByOverlay = new Map(); const MAX_RECENT_PER_OVERLAY = 100; const MAX_RECENT_AGE_MS = 5 * 60 * 1000; function cleanText(value, max = 500) { return String(value === undefined || value === null ? "" : value).trim().slice(0, max); } function cleanMessageText(value, max = 2000) { return String(value === undefined || value === null ? "" : value).slice(0, max); } function cleanUrl(value) { const candidate = cleanText(value, 2048); if (!candidate) return null; try { const parsed = new URL(candidate); return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : null; } catch { return null; } } function normalizeBadge(badge) { if (typeof badge === "string") { const label = cleanText(badge, 40); return label ? { label, image: null } : null; } const label = cleanText(badge?.label || badge?.name, 40); if (!label) return null; return { label, image: cleanUrl(badge?.image || badge?.url) }; } function normalizeEmote(emote) { const parsedStart = Number(emote?.start); const parsedEnd = Number(emote?.end); if (!Number.isFinite(parsedStart) || !Number.isFinite(parsedEnd)) return null; const start = Math.max(0, Math.round(parsedStart)); const end = Math.max(start, Math.round(parsedEnd)); const image = cleanUrl(emote?.image || emote?.url); return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null; } function normalizeMedia(media) { const url = cleanUrl(media?.url); if (!url) return null; return { url, type: media?.type === "video" ? "video" : "image", alt: cleanText(media?.alt || "Animated image", 160) }; } function normalizeChatMessage(input = {}) { const platform = cleanText(input.platform, 24).toLowerCase(); const text = cleanMessageText(input.text, 2000); const authorId = cleanText(input.author?.id, 160); const media = (Array.isArray(input.media) ? input.media : []).map(normalizeMedia).filter(Boolean).slice(0, 4); if (!platform || (!text.trim() && !media.length) || !authorId) return null; const channelId = cleanText(input.channel?.id, 256); const channelName = cleanText(input.channel?.name, 256); const channelKey = cleanText(input.channel?.key || channelName || channelId, 256); return { id: cleanText(input.id, 256) || crypto.randomUUID(), platform, text, timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(), channel: { id: channelId, name: channelName, key: channelKey }, emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100), media, author: { id: authorId, name: cleanText(input.author?.name || input.author?.username || "Viewer", 160), username: cleanText(input.author?.username, 160), avatar: cleanUrl(input.author?.avatar), color: /^#[0-9a-f]{6}$/i.test(cleanText(input.author?.color, 7)) ? cleanText(input.author.color, 7) : null, badges: (Array.isArray(input.author?.badges) ? input.author.badges : []).map(normalizeBadge).filter(Boolean).slice(0, 12), lumi: input.author?.lumi?.id ? { id: cleanText(input.author.lumi.id, 160), username: cleanText(input.author.lumi.username, 160) } : null } }; } function normalizedChannel(value) { return String(value || "").trim().toLowerCase().replace(/^#/, ""); } function messageMatchesConfig(message, config = {}) { const platforms = Array.isArray(config.platforms) ? config.platforms : ["twitch", "youtube", "discord"]; if (!platforms.includes(message.platform) || messageBlockedByConfig(message, config)) return false; const filters = Array.isArray(config.channels) ? config.channels : []; if (!filters.length) return true; const aliases = [message.channel?.id, message.channel?.name, message.channel?.key].map(normalizedChannel).filter(Boolean); return filters.some((entry) => { const raw = String(entry || "").trim(); const separator = raw.indexOf(":"); const platform = separator > 0 ? raw.slice(0, separator).trim().toLowerCase() : ""; const channel = normalizedChannel(separator > 0 ? raw.slice(separator + 1) : raw); return (!platform || platform === message.platform) && (channel === "*" || aliases.includes(channel)); }); } function messageBlockedByConfig(message, config = {}) { const rules = Array.isArray(config.blockedUsers) ? config.blockedUsers : []; return rules.some((entry) => { const raw = String(entry || "").trim(); const separator = raw.indexOf(":"); if (separator < 1) return false; const platform = raw.slice(0, separator).toLowerCase(); const values = raw.slice(separator + 1).split("|").map((value) => normalizedIdentity(value)).filter(Boolean); const aliases = platform === "lumi" ? [message.author?.lumi?.id, message.author?.lumi?.username] : platform === message.platform ? [message.author?.id, message.author?.username, message.author?.name] : []; const normalizedAliases = aliases.map(normalizedIdentity).filter(Boolean); return values.some((value) => normalizedAliases.includes(value)); }); } function normalizedIdentity(value) { return String(value || "").trim().toLowerCase().replace(/^@/, ""); } function overlayTargets(message) { const rows = db.prepare( `SELECT s.overlay_id, m.type, m.config_json FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id JOIN overlays o ON o.id = s.overlay_id WHERE m.enabled = 1 AND s.enabled = 1 AND o.enabled = 1` ).all(); const targets = new Set(); for (const row of rows) { if (getOverlayModuleType(row.type)?.renderType !== "chat") continue; let config = {}; try { config = JSON.parse(row.config_json || "{}"); } catch {} if (messageMatchesConfig(message, config)) targets.add(row.overlay_id); } return [...targets]; } function remember(overlayId, message) { const cutoff = Date.now() - MAX_RECENT_AGE_MS; const recent = (recentByOverlay.get(overlayId) || []).filter((entry) => entry.timestamp >= cutoff && entry.id !== message.id); recent.push(message); recentByOverlay.set(overlayId, recent.slice(-MAX_RECENT_PER_OVERLAY)); } function publishOverlayChatMessage(input = {}) { const message = normalizeChatMessage(input); if (!message) return 0; let delivered = 0; for (const overlayId of overlayTargets(message)) { remember(overlayId, message); delivered += publishWebEvent("overlay:chat-message", message, { scope: `overlay:${overlayId}` }); } return delivered; } function recentOverlayChatMessages(overlayId) { const cutoff = Date.now() - MAX_RECENT_AGE_MS; const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff); recentByOverlay.set(String(overlayId), recent); return recent.map((payload) => ({ event: "overlay:chat-message", payload })); } module.exports = { messageBlockedByConfig, messageMatchesConfig, normalizeChatMessage, publishOverlayChatMessage, recentOverlayChatMessages };