Lumi/src/services/overlay-chat.js
2026-07-19 13:01:16 +02:00

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
};