182 lines
7.1 KiB
JavaScript
182 lines
7.1 KiB
JavaScript
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
|
|
};
|