"use strict"; const MAX_MESSAGE_BYTES = 64 * 1024; const MAX_SELECTED_SOURCES = 250; const MAX_SELECTED_EVENTS = 100; function parseClientMessage(data) { const body = Buffer.from(data); if (body.length > MAX_MESSAGE_BYTES) throw coded("MESSAGE_TOO_LARGE", "Overlay control message is too large."); let value; try { value = JSON.parse(body.toString("utf8")); } catch { throw coded("INVALID_JSON", "Overlay control message must be valid JSON."); } if (!value || typeof value !== "object" || Array.isArray(value)) throw coded("INVALID_MESSAGE", "Overlay control message must be an object."); const type = String(value.type || "").trim(); if (!["subscribe", "ping", "pong"].includes(type)) throw coded("INVALID_MESSAGE", `Unsupported overlay control message '${type || "empty"}'.`); if (type !== "subscribe") return { type }; return { type, sources: uniqueStrings(value.sources, MAX_SELECTED_SOURCES, 300), events: uniqueStrings(value.events, MAX_SELECTED_EVENTS, 120) }; } function uniqueStrings(value, limit, maxLength) { if (!Array.isArray(value)) return []; return [...new Set(value.map((entry) => String(entry || "").trim().slice(0, maxLength)).filter(Boolean))].slice(0, limit); } function cleanText(value, max = 500) { return String(value === undefined || value === null ? "" : value).slice(0, max); } function sanitizeChat(message) { return { id: cleanText(message.id, 256), platform: cleanText(message.platform, 24), text: cleanText(message.text, 2000), timestamp: Number(message.timestamp) || Date.now(), channel: { id: cleanText(message.channel?.id, 256), name: cleanText(message.channel?.name, 256), key: cleanText(message.channel?.key, 256) }, author: { id: cleanText(message.author?.id, 160), name: cleanText(message.author?.name, 160), username: cleanText(message.author?.username, 160), avatar: safeHttps(message.author?.avatar), color: /^#[0-9a-f]{6}$/i.test(message.author?.color || "") ? message.author.color : null, badges: (Array.isArray(message.author?.badges) ? message.author.badges : []).slice(0, 12).map((badge) => ({ label: cleanText(badge?.label, 40), image: safeHttps(badge?.image) })) }, emotes: (Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({ start: Math.max(0, Number(emote?.start) || 0), end: Math.max(0, Number(emote?.end) || 0), label: cleanText(emote?.label, 100), image: safeHttps(emote?.image) })).filter((emote) => emote.image), media: (Array.isArray(message.media) ? message.media : []).slice(0, 4).map((media) => ({ url: safeHttps(media?.url), type: media?.type === "video" ? "video" : "image", alt: cleanText(media?.alt, 160), preview: safeHttps(media?.preview) })).filter((media) => media.url) }; } function sanitizeEvent(event) { return { id: cleanText(event.id, 256), type: cleanText(event.type, 120), source: cleanText(event.source, 80), occurred_at: Number(event.occurredAt) || Date.now(), payload: sanitizeObject(event.payload, 0) }; } function sanitizeObject(value, depth) { if (depth > 3 || value === null || value === undefined) return null; if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizeObject(item, depth + 1)); if (typeof value === "object") { const output = {}; for (const [key, item] of Object.entries(value).slice(0, 50)) { if (/token|secret|authorization|credential|oauth/i.test(key)) continue; output[cleanText(key, 80)] = sanitizeObject(item, depth + 1); } return output; } if (typeof value === "string") return cleanText(value, 1000); return typeof value === "number" || typeof value === "boolean" ? value : null; } function safeHttps(value) { try { const parsed = new URL(String(value || "")); return parsed.protocol === "https:" ? parsed.toString() : null; } catch { return null; } } function coded(code, message) { return Object.assign(new Error(message), { code }); } module.exports = { MAX_MESSAGE_BYTES, parseClientMessage, sanitizeChat, sanitizeEvent };