const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const Database = require("better-sqlite3");
const ROOT = __dirname;
const DATA_DIR = path.join(ROOT, "data");
const PUBLIC_DIR = path.join(ROOT, "public");
const UPLOAD_DIR = path.join(DATA_DIR, "uploads");
const DB_PATH = path.join(DATA_DIR, "maalflows.sqlite");
loadDotEnv(path.join(ROOT, ".env"));
const PORT = Number(process.env.PORT || 3400);
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me";
const COOKIE_NAME = "mf_session";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
const OPENAI_TRANSLATION_MODEL = process.env.OPENAI_TRANSLATION_MODEL || "gpt-5-mini";
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID || "";
const TELEGRAM_WEBHOOK_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET || "";
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
const MAX_ATTACHMENT_BYTES = 3_000_000;
const WIDGET_COPY_FIELDS = ["title", "intro", "offlineIntro", "placeholder", "sendLabel", "dropzoneLabel", "onlineLabel", "offlineLabel", "newReplyLabel", "typingLabel", "infoTitle", "infoText", "closeLabel", "operatorReplyLabel"];
const EUROPEAN_WIDGET_LANGUAGES = new Set([
"sq", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
"hu", "is", "ga", "it", "lv", "lt", "mk", "mt", "no", "pl", "pt", "ro", "ru", "sr", "sk",
"sl", "es", "sv", "tr", "uk"
]);
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initDb();
seedDefaults();
const streams = new Map();
const operatorPresence = new Map();
const telegramReplyContexts = new Map();
const server = http.createServer(async (req, res) => {
try {
await route(req, res);
} catch (error) {
console.error(error);
json(res, 500, { error: "internal_error" });
}
});
server.listen(PORT, () => {
console.log(`MaalFlows running on http://localhost:${PORT}`);
});
async function route(req, res) {
const url = new URL(req.url, PUBLIC_BASE_URL);
setCommonHeaders(res);
if (req.method === "OPTIONS") return endCors(res);
if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true });
if (req.method === "GET" && url.pathname === "/favicon.svg") return file(res, path.join(PUBLIC_DIR, "favicon.svg"), "image/svg+xml; charset=utf-8");
if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8");
if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8");
if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname);
if (req.method === "GET" && url.pathname === "/widget.js") {
res.setHeader("Cache-Control", "no-store, max-age=0");
return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8");
}
if (req.method === "GET" && url.pathname.startsWith("/admin.")) return servePublic(res, url.pathname);
if (url.pathname.match(/^\/api\/telegram\/webhook\/[^/]+$/) && req.method === "POST") return telegramWebhook(req, res, url);
if (url.pathname === "/api/admin/login" && req.method === "POST") return adminLogin(req, res);
if (url.pathname === "/api/admin/logout" && req.method === "POST") return adminLogout(res);
if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url));
if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url);
if (url.pathname === "/api/widget/visitors/ping" && req.method === "POST") return visitorPing(req, res);
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/seen$/) && req.method === "POST") return markVisitorSeen(req, res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor");
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url);
return json(res, 404, { error: "not_found" });
}
async function adminApi(req, res, url) {
if (url.pathname === "/api/admin/me" && req.method === "GET") return json(res, 200, { user: req.adminUser });
if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res);
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
if (url.pathname === "/api/admin/users" && req.method === "GET") return adminUsers(res);
if (url.pathname === "/api/admin/users" && req.method === "POST") return createAdminUser(req, res);
if (url.pathname.match(/^\/api\/admin\/users\/\d+$/) && req.method === "PATCH") return updateAdminUser(req, res, url);
if (url.pathname.match(/^\/api\/admin\/users\/\d+$/) && req.method === "DELETE") return deleteAdminUser(req, res, url);
if (url.pathname === "/api/admin/ai/models" && req.method === "GET") return adminAiModels(res);
if (url.pathname === "/api/admin/ai/test" && req.method === "POST") return adminAiTest(res);
if (url.pathname === "/api/admin/telegram/test" && req.method === "POST") return adminTelegramTest(res);
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
if (url.pathname === "/api/admin/visitors" && req.method === "GET") return adminVisitors(res);
if (url.pathname.match(/^\/api\/admin\/visitors\/[^/]+$/) && req.method === "PATCH") return updateVisitorProfile(req, res, url);
if (url.pathname.match(/^\/api\/admin\/attachments\/\d+$/) && req.method === "DELETE") return deleteAttachment(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/presence$/) && req.method === "POST") return adminPresence(req, res, url);
return json(res, 404, { error: "not_found" });
}
function initDb() {
db.exec(`
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
site_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
domain TEXT,
is_online INTEGER NOT NULL DEFAULT 1,
settings_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
chat_nick TEXT,
password_hash TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL REFERENCES companies(id),
site_id INTEGER NOT NULL REFERENCES sites(id),
status TEXT NOT NULL DEFAULT 'new',
visitor_token TEXT NOT NULL,
visitor_name TEXT,
visitor_email TEXT,
visitor_phone TEXT,
current_url TEXT,
referrer TEXT,
browsing_history_json TEXT NOT NULL DEFAULT '[]',
device_json TEXT NOT NULL DEFAULT '{}',
language TEXT,
timezone TEXT,
ip TEXT,
country_code TEXT,
customer_language TEXT,
vpn_risk INTEGER NOT NULL DEFAULT 0,
visitor_seen_at TEXT,
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
sender_type TEXT NOT NULL,
sender_name TEXT,
client_message_id TEXT,
original_language TEXT,
translated_body TEXT,
translated_language TEXT,
translation_status TEXT,
translation_error TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
message_id INTEGER REFERENCES messages(id),
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
byte_size INTEGER NOT NULL,
storage_path TEXT NOT NULL,
public_url TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS visitor_sessions (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
site_id INTEGER NOT NULL REFERENCES sites(id),
visitor_token TEXT NOT NULL,
current_url TEXT,
referrer TEXT,
browsing_history_json TEXT NOT NULL DEFAULT '[]',
device_json TEXT NOT NULL DEFAULT '{}',
language TEXT,
timezone TEXT,
ip TEXT,
country_code TEXT,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_activity_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(site_id, visitor_token)
);
CREATE TABLE IF NOT EXISTS visitor_profiles (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
site_id INTEGER NOT NULL REFERENCES sites(id),
visitor_token TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(site_id, visitor_token)
);
CREATE TABLE IF NOT EXISTS telegram_message_links (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL REFERENCES sites(id),
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
telegram_chat_id TEXT NOT NULL,
telegram_message_id INTEGER NOT NULL,
source_message_id INTEGER REFERENCES messages(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(telegram_chat_id, telegram_message_id)
);
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_visitor_sessions_seen ON visitor_sessions(site_id, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_visitor_profiles_token ON visitor_profiles(site_id, visitor_token);
CREATE INDEX IF NOT EXISTS idx_telegram_links_conversation ON telegram_message_links(conversation_id);
`);
if (ensureColumn("conversations", "admin_seen_at", "TEXT")) {
db.prepare("UPDATE conversations SET admin_seen_at = last_message_at WHERE admin_seen_at IS NULL").run();
}
if (ensureColumn("visitor_sessions", "last_activity_at", "TEXT")) {
db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run();
}
ensureColumn("conversations", "visitor_seen_at", "TEXT");
ensureColumn("conversations", "customer_language", "TEXT");
ensureColumn("messages", "client_message_id", "TEXT");
ensureColumn("messages", "original_language", "TEXT");
ensureColumn("messages", "translated_body", "TEXT");
ensureColumn("messages", "translated_language", "TEXT");
ensureColumn("messages", "translation_status", "TEXT");
ensureColumn("messages", "translation_error", "TEXT");
ensureColumn("admin_users", "password_hash", "TEXT");
ensureColumn("admin_users", "chat_nick", "TEXT");
ensureColumn("admin_users", "is_system", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("admin_users", "updated_at", "TEXT");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_id ON messages(conversation_id, client_message_id) WHERE client_message_id IS NOT NULL");
}
function ensureColumn(table, column, definition) {
const exists = db.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column);
if (exists) return false;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
return true;
}
function seedDefaults() {
const companyName = process.env.DEFAULT_COMPANY_NAME || "MAAL";
const siteKey = process.env.DEFAULT_SITE_KEY || "9b-plus";
const existingCompany = db.prepare("SELECT id FROM companies LIMIT 1").get();
const companyId = existingCompany?.id || db.prepare("INSERT INTO companies (name) VALUES (?)").run(companyName).lastInsertRowid;
const settings = defaultSettings();
const existingSite = db.prepare("SELECT id FROM sites WHERE site_key = ?").get(siteKey);
if (!existingSite) {
db.prepare(`
INSERT INTO sites (company_id, site_key, name, domain, settings_json)
VALUES (?, ?, ?, ?, ?)
`).run(
companyId,
siteKey,
process.env.DEFAULT_SITE_NAME || "9b-plus support",
process.env.DEFAULT_SITE_DOMAIN || "https://www.9b-plus.com",
JSON.stringify(settings)
);
}
for (const user of configuredUsers()) {
db.prepare(`
INSERT INTO admin_users (company_id, username, display_name, chat_nick, is_system, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(username) DO UPDATE SET
display_name = excluded.display_name,
chat_nick = excluded.chat_nick,
is_system = 1,
updated_at = CURRENT_TIMESTAMP
`).run(companyId, user.username, user.displayName || user.username, user.chatNick || user.displayName || user.username);
}
}
function defaultSettings() {
const widgetCopy = {
cs: defaultWidgetCopy("cs"),
en: defaultWidgetCopy("en")
};
return {
widgetLanguage: {
mode: "auto",
defaultLanguage: "cs"
},
widgetCopy,
title: { cs: widgetCopy.cs.title, en: widgetCopy.en.title },
intro: { cs: widgetCopy.cs.intro, en: widgetCopy.en.intro },
offlineIntro: { cs: widgetCopy.cs.offlineIntro, en: widgetCopy.en.offlineIntro },
placeholder: { cs: widgetCopy.cs.placeholder, en: widgetCopy.en.placeholder },
sendLabel: { cs: widgetCopy.cs.sendLabel, en: widgetCopy.en.sendLabel },
detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" },
colors: {
brand: "#0f8f6f",
brandText: "#ffffff",
surface: "#ffffff",
text: "#17211d",
muted: "#647067"
},
desktop: { side: "right", bottomPx: 22, sideOffsetPx: 22, widthPx: 370 },
mobile: { side: "right", bottomPx: 14, sideOffsetPx: 12, widthPx: 340 },
launcherEffects: {
shape: "bubble",
breathe: true,
ring: true,
hoverLabel: true,
statusDot: true,
persistentLabel: false,
sway: false,
avatar: false
},
notifications: {
pulseOnAdminReply: true,
badgeOnAdminReply: true,
labelOnAdminReply: true,
wiggleOnAdminReply: true
},
translations: {
enabled: false,
operatorLanguage: "cs",
model: OPENAI_TRANSLATION_MODEL,
openaiApiKey: ""
},
telegram: {
enabled: false,
botToken: TELEGRAM_BOT_TOKEN,
chatId: TELEGRAM_CHAT_ID,
webhookSecret: TELEGRAM_WEBHOOK_SECRET || id("tgsec"),
operatorName: "Alex"
},
adminPresence: {
activeSeconds: 30,
offlineSeconds: 120,
clusterBotFilterEnabled: true,
clusterBotCount: 20,
clusterBotWindowSeconds: 60
}
};
}
function defaultWidgetCopy(language) {
if (language === "en") {
return {
title: "Need help?",
intro: "Message us and we will reply as soon as possible.",
offlineIntro: "We are offline, but you can leave us a message.",
placeholder: "Write a message...",
sendLabel: "Send",
dropzoneLabel: "Add a photo or drag it here",
onlineLabel: "We are online",
offlineLabel: "Offline",
newReplyLabel: "New reply",
typingLabel: "Operator is typing...",
infoTitle: "Privacy information",
infoText: "We process your message and technical session data only to answer your request.",
closeLabel: "Minimize chat",
operatorReplyLabel: "is replying"
};
}
return {
title: "Potřebujete poradit?",
intro: "Napište nám. Odpovíme co nejdříve.",
offlineIntro: "Teď nejsme online, ale zprávu nám můžete nechat.",
placeholder: "Napište zprávu...",
sendLabel: "Odeslat",
dropzoneLabel: "Přidat fotku nebo přetáhnout",
onlineLabel: "Jsme online",
offlineLabel: "Offline",
newReplyLabel: "Nová odpověď",
typingLabel: "Operátor píše...",
infoTitle: "GDPR informace",
infoText: "Zprávu a technické údaje relace zpracujeme jen pro odpověď na váš dotaz.",
closeLabel: "Zmenšit chat",
operatorReplyLabel: "odpovídá"
};
}
function legacyCzechWidgetCopy() {
return {
title: "Potrebujete poradit?",
intro: "Napiste nam. Odpovime co nejdrive.",
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
placeholder: "Napiste zpravu...",
sendLabel: "Odeslat",
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
offlineLabel: "Offline",
newReplyLabel: "Nova odpoved",
typingLabel: "Operator pise...",
infoTitle: "GDPR informace",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
closeLabel: "Zmensit chat",
operatorReplyLabel: "odpovida"
};
}
function configuredUsers() {
const users = [];
for (let i = 1; i < 50; i += 1) {
const username = process.env[`USER_${i}`];
const password = process.env[`PASS_${i}`];
const displayName = process.env[`NAME_${i}`] || username;
const chatNick = process.env[`NICK_${i}`] || displayName;
if (!username && !password) break;
if (username && password) users.push({ username, password, displayName, chatNick });
}
return users;
}
function adminProfile(username) {
const configured = configuredUsers().find((user) => user.username === username);
if (configured) return { username: configured.username, displayName: configured.displayName, chatNick: configured.chatNick };
const row = db.prepare("SELECT username, display_name, chat_nick FROM admin_users WHERE username = ?").get(username);
return row ? { username: row.username, displayName: row.display_name, chatNick: row.chat_nick || row.display_name } : { username, displayName: username, chatNick: username };
}
async function adminLogin(req, res) {
const body = await readJson(req);
const username = String(body.username || "").trim();
const password = String(body.password || "");
const configured = configuredUsers().find((u) => u.username === username && u.password === password);
const dbUser = configured ? null : db.prepare("SELECT * FROM admin_users WHERE username = ? AND password_hash IS NOT NULL").get(username);
const match = configured || (dbUser && verifyPassword(password, dbUser.password_hash)
? { username: dbUser.username, displayName: dbUser.display_name, chatNick: dbUser.chat_nick || dbUser.display_name }
: null);
if (!match) return json(res, 401, { error: "bad_credentials" });
const token = signSession(match.username);
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`);
return json(res, 200, { ok: true, user: { username: match.username, displayName: match.displayName, chatNick: match.chatNick } });
}
function adminLogout(res) {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`);
return json(res, 200, { ok: true });
}
function requireAdmin(req, res, next) {
const token = parseCookies(req.headers.cookie || "")[COOKIE_NAME];
const username = token && verifySession(token);
if (!username) return json(res, 401, { error: "unauthorized" });
req.adminUser = adminProfile(username);
return next();
}
function adminBootstrap(res) {
const site = getDefaultSite();
return json(res, 200, {
publicBaseUrl: PUBLIC_BASE_URL,
site: formatSite(site),
snippet: snippetFor(site)
});
}
function adminConversations(res) {
const presence = adminPresenceSettings();
const rows = db.prepare(`
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,
(SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body,
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
EXISTS(
SELECT 1 FROM messages unread
WHERE unread.conversation_id = c.id
AND unread.sender_type = 'visitor'
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
) AS has_unread,
EXISTS(
SELECT 1 FROM visitor_sessions vs
WHERE vs.site_id = c.site_id
AND vs.visitor_token = c.visitor_token
AND datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) >= datetime('now', '-${presence.offlineSeconds} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) < datetime('now', '-${presence.offlineSeconds} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active'
ELSE 'idle'
END
FROM visitor_sessions vs
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 1
), 'offline') AS visitor_presence,
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
FROM conversations c
JOIN sites s ON s.id = c.site_id
LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
LIMIT 200
`).all();
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
}
function adminVisitors(res) {
return json(res, 200, { visitors: currentVisitors() });
}
async function updateVisitorProfile(req, res, url) {
const site = getDefaultSite();
const visitorToken = decodeURIComponent(url.pathname.split("/").pop() || "");
if (!visitorToken) return json(res, 400, { error: "visitor_required" });
const body = await readJson(req);
const displayName = normalizeDisplayName(body.displayName);
db.prepare(`
INSERT INTO visitor_profiles (company_id, site_id, visitor_token, display_name)
VALUES (?, ?, ?, ?)
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
display_name = excluded.display_name,
updated_at = CURRENT_TIMESTAMP
`).run(site.company_id, site.id, visitorToken, displayName);
const conversations = db.prepare(`
SELECT public_id FROM conversations
WHERE site_id = ? AND visitor_token = ?
ORDER BY datetime(last_message_at) DESC
`).all(site.id, visitorToken);
const current = currentVisitors();
publish("admin", { type: "visitors:update", visitors: current });
for (const item of conversations) {
const conversation = findConversation(item.public_id);
publishConversation(conversation, { type: "conversation:update", conversation: formatConversationSummary(conversation) });
}
return json(res, 200, {
ok: true,
visitorToken,
displayName,
label: displayName || visitorLabel(visitorToken)
});
}
function adminConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
markConversationSeen(conversation.id);
return json(res, 200, conversationDetails(findConversation(conversation.public_id)));
}
async function updateSite(req, res) {
const body = await readJson(req);
const site = getDefaultSite();
const currentSettings = parseJson(site.settings_json, defaultSettings());
const incomingSettings = body.settings || {};
const settings = mergeSiteSettings(currentSettings, incomingSettings);
const widgetCopySync = await syncChangedWidgetCopyTranslations(currentSettings, settings);
syncLegacyWidgetCopy(settings);
db.prepare(`
UPDATE sites
SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(body.isOnline ? 1 : 0, JSON.stringify(settings), site.id);
const updated = getDefaultSite();
publish("admin", { type: "site:update", site: formatSite(updated) });
return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated), widgetCopySync });
}
function adminUsers(res) {
return json(res, 200, { users: listAdminUsers() });
}
async function createAdminUser(req, res) {
const body = await readJson(req);
const companyId = getDefaultSite().company_id;
const username = normalizeAdminUsername(body.username);
const displayName = normalizeDisplayName(body.displayName) || username;
const chatNick = normalizeDisplayName(body.chatNick) || displayName;
const password = String(body.password || "");
if (!username) return json(res, 400, { error: "bad_username" });
if (!validAdminPassword(password)) return json(res, 400, { error: "bad_password" });
try {
db.prepare(`
INSERT INTO admin_users (company_id, username, display_name, chat_nick, password_hash, is_system, updated_at)
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
`).run(companyId, username, displayName, chatNick, hashPassword(password));
return json(res, 201, { users: listAdminUsers() });
} catch (error) {
if (String(error.message || "").includes("UNIQUE")) return json(res, 409, { error: "username_exists" });
throw error;
}
}
async function updateAdminUser(req, res, url) {
const body = await readJson(req);
const id = Number(url.pathname.split("/").pop());
const user = db.prepare("SELECT * FROM admin_users WHERE id = ?").get(id);
if (!user) return json(res, 404, { error: "user_not_found" });
const displayName = normalizeDisplayName(body.displayName);
const chatNick = normalizeDisplayName(body.chatNick);
const password = String(body.password || "");
if (displayName) {
if (user.is_system) return json(res, 400, { error: "system_user_profile_env" });
db.prepare("UPDATE admin_users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(displayName, id);
}
if (chatNick) {
if (user.is_system) return json(res, 400, { error: "system_user_profile_env" });
db.prepare("UPDATE admin_users SET chat_nick = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(chatNick, id);
}
if (password) {
if (user.is_system) return json(res, 400, { error: "system_user_password_env" });
if (!validAdminPassword(password)) return json(res, 400, { error: "bad_password" });
db.prepare("UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(hashPassword(password), id);
}
return json(res, 200, { users: listAdminUsers() });
}
function deleteAdminUser(req, res, url) {
const id = Number(url.pathname.split("/").pop());
const user = db.prepare("SELECT * FROM admin_users WHERE id = ?").get(id);
if (!user) return json(res, 404, { error: "user_not_found" });
if (user.is_system) return json(res, 400, { error: "system_user_protected" });
if (user.username === req.adminUser.username) return json(res, 400, { error: "cannot_delete_current_user" });
db.prepare("DELETE FROM admin_users WHERE id = ?").run(id);
return json(res, 200, { users: listAdminUsers() });
}
function listAdminUsers() {
return db.prepare(`
SELECT id, username, display_name, chat_nick, password_hash IS NOT NULL AS has_password, is_system, created_at, updated_at
FROM admin_users
ORDER BY username COLLATE NOCASE ASC
`).all().map((user) => ({
id: user.id,
username: user.username,
displayName: user.display_name,
chatNick: user.chat_nick || user.display_name,
hasPassword: Boolean(user.has_password || user.is_system),
isSystem: Boolean(user.is_system),
createdAt: user.created_at,
updatedAt: user.updated_at
}));
}
async function adminAiModels(res) {
const settings = getTranslationSettings();
const apiKey = settings.openaiApiKey;
if (!apiKey) return json(res, 400, { error: "missing_openai_api_key", models: fallbackTranslationModels() });
try {
const response = await fetch("https://api.openai.com/v1/models", {
headers: { "Authorization": `Bearer ${apiKey}` }
});
if (!response.ok) throw new Error(`OpenAI HTTP ${response.status}`);
const data = await response.json();
const models = (data.data || [])
.map((item) => item.id)
.filter(isUsableTextModel)
.sort((a, b) => a.localeCompare(b));
return json(res, 200, { models: models.length ? models : fallbackTranslationModels() });
} catch (error) {
console.error("openai_models_failed", error.message);
return json(res, 502, { error: "openai_models_failed", models: fallbackTranslationModels() });
}
}
async function adminAiTest(res) {
const settings = getTranslationSettings();
const apiKey = settings.openaiApiKey;
if (!apiKey) return json(res, 400, { ok: false, error: "missing_openai_api_key" });
try {
const modelsResponse = await fetch("https://api.openai.com/v1/models", {
headers: { "Authorization": `Bearer ${apiKey}` }
});
if (!modelsResponse.ok) {
const openAiError = await openAiErrorFromResponse(modelsResponse);
return json(res, 400, { ok: false, error: openAiError.code, message: openAiError.message });
}
const data = await modelsResponse.json();
await translateText("Test", settings.operatorLanguage || "cs", settings.model, apiKey);
return json(res, 200, {
ok: true,
key: maskApiKey(apiKey),
model: settings.model,
modelCount: Array.isArray(data.data) ? data.data.length : 0
});
} catch (error) {
console.error("openai_test_failed", error.message);
return json(res, 400, { ok: false, error: classifyOpenAiError(error.message), message: error.message });
}
}
async function adminTelegramTest(res) {
const settings = getTelegramSettings();
if (!settings.enabled) return json(res, 400, { ok: false, error: "telegram_disabled", message: "Telegram neni zapnuty." });
if (!settings.botToken || !settings.chatId) return json(res, 400, { ok: false, error: "missing_telegram_settings", message: "Chybi bot token nebo chat ID." });
if (!settings.webhookSecret) return json(res, 400, { ok: false, error: "missing_webhook_secret", message: "Chybi webhook secret." });
try {
await telegramSetWebhook(settings);
await telegramSendMessage(settings, {
text: `MaalFlows test\nTelegram notifikace funguje.\n\nReply na notifikaci od zakaznika poslouzi jako odpoved do chatu.`,
replyMarkup: {
inline_keyboard: [[{ text: "Otevrit BO", url: `${PUBLIC_BASE_URL}/admin` }]]
}
});
return json(res, 200, { ok: true, webhookUrl: telegramWebhookUrl(settings) });
} catch (error) {
console.error("telegram_test_failed", error.message);
return json(res, 400, { ok: false, error: "telegram_failed", message: error.message });
}
}
async function telegramWebhook(req, res, url) {
const secret = decodeURIComponent(url.pathname.split("/").pop() || "");
const settings = getTelegramSettings();
if (!settings.enabled || !settings.webhookSecret || secret !== settings.webhookSecret) return json(res, 403, { ok: false });
const update = await readJson(req, 2_000_000).catch(() => ({}));
try {
await handleTelegramUpdate(update, settings);
} catch (error) {
console.error("telegram_webhook_failed", error.message);
}
return json(res, 200, { ok: true });
}
async function updateConversationStatus(req, res, url) {
const body = await readJson(req);
const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null;
if (!status) return json(res, 400, { error: "bad_status" });
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
db.prepare("UPDATE conversations SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, conversation.id);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "conversation:status", conversation: formatConversationSummary(updated) });
return json(res, 200, { conversation: formatConversationSummary(updated) });
}
function deleteConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
const attachments = db.prepare("SELECT storage_path FROM attachments WHERE conversation_id = ?").all(conversation.id);
db.transaction(() => {
db.prepare("DELETE FROM attachments WHERE conversation_id = ?").run(conversation.id);
db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversation.id);
db.prepare("DELETE FROM conversations WHERE id = ?").run(conversation.id);
})();
for (const attachment of attachments) {
if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) {
fs.rmSync(attachment.storage_path, { force: true });
}
}
publish("admin", { type: "conversation:delete", conversationId: conversation.public_id });
return json(res, 200, { ok: true });
}
async function widgetConfig(res, url) {
const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus";
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
if (!site) return json(res, 404, { error: "site_not_found" });
const language = widgetLanguageForRequest(site, url);
const copy = await widgetCopyForLanguage(site, language);
return json(res, 200, { site: formatSite(getSiteById(site.id)), publicBaseUrl: PUBLIC_BASE_URL, requestedLanguage: language, language: copy.language, copy: copy.copy });
}
async function visitorPing(req, res) {
const body = await readJson(req);
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
if (!site) return json(res, 404, { error: "site_not_found" });
const visitorToken = body.visitorToken || id("vis");
const lastActivityAt = timestampOrNow(body.lastActivityAt);
db.prepare(`
INSERT INTO visitor_sessions (
company_id, site_id, visitor_token, current_url, referrer, browsing_history_json,
device_json, language, timezone, ip, country_code, first_seen_at, last_activity_at, last_seen_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP)
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
current_url = excluded.current_url,
referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
browsing_history_json = excluded.browsing_history_json,
device_json = excluded.device_json,
language = excluded.language,
timezone = excluded.timezone,
ip = excluded.ip,
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
last_activity_at = CASE
WHEN datetime(excluded.last_activity_at) > datetime(visitor_sessions.last_activity_at)
THEN excluded.last_activity_at
ELSE visitor_sessions.last_activity_at
END,
last_seen_at = CURRENT_TIMESTAMP
`).run(
site.company_id,
site.id,
visitorToken,
nullish(body.currentUrl),
nullish(body.referrer),
JSON.stringify(body.browsingHistory || []),
JSON.stringify(body.device || {}),
nullish(body.language),
nullish(body.timezone),
clientIp(req),
nullish(countryFromHeaders(req)),
lastActivityAt
);
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
return json(res, 200, { ok: true });
}
function getVisitorConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
if (conversation.visitor_token !== url.searchParams.get("visitorToken")) return json(res, 403, { error: "forbidden" });
return json(res, 200, conversationDetails(conversation));
}
async function createConversation(req, res) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
if (!site) return json(res, 404, { error: "site_not_found" });
const visitorToken = body.visitorToken || id("vis");
const clientMessageId = normalizeClientMessageId(body.clientMessageId);
const existingConversation = findConversationByClientMessage(site.id, visitorToken, clientMessageId);
if (existingConversation) return json(res, 200, conversationDetails(existingConversation));
const publicId = id("cnv");
const info = body.visitorInfo || {};
const ip = clientIp(req);
const insertConversation = db.transaction(() => {
const conversationId = db.prepare(`
INSERT INTO conversations (
public_id, company_id, site_id, visitor_token, visitor_name, visitor_email, visitor_phone,
current_url, referrer, browsing_history_json, device_json, language, timezone, ip, country_code
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
publicId,
site.company_id,
site.id,
visitorToken,
nullish(info.name),
nullish(info.email),
nullish(info.phone),
nullish(body.currentUrl),
nullish(body.referrer),
JSON.stringify(body.browsingHistory || []),
JSON.stringify(body.device || {}),
nullish(body.language),
nullish(body.timezone),
ip,
nullish(countryFromHeaders(req))
).lastInsertRowid;
const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message || "").trim(), clientMessageId);
saveAttachments(conversationId, messageId, body.attachments || []);
return { conversationId, messageId };
});
const insert = insertConversation();
await translateStoredMessage(insert.conversationId, insert.messageId, "visitor", body);
const conversation = findConversation(publicId);
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
notifyTelegramVisitorMessage(conversation, insert.messageId).catch((error) => console.error("telegram_notify_failed", error.message));
return json(res, 201, conversationDetails(conversation));
}
async function createVisitorMessage(req, res, url) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
const clientMessageId = normalizeClientMessageId(body.clientMessageId);
if (clientMessageId && messageExists(conversation.id, clientMessageId)) {
updateVisitorContext(conversation.id, body);
return json(res, 200, conversationDetails(findConversation(conversation.public_id)));
}
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message || "").trim(), clientMessageId);
saveAttachments(conversation.id, messageId, body.attachments || []);
updateVisitorContext(conversation.id, body);
await translateStoredMessage(conversation.id, messageId, "visitor", body);
markConversationUnread(conversation.id);
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
notifyTelegramVisitorMessage(updated, messageId).catch((error) => console.error("telegram_notify_failed", error.message));
return json(res, 201, conversationDetails(updated));
}
async function markVisitorSeen(req, res, url) {
const body = await readJson(req).catch(() => ({}));
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
if (conversation.visitor_token !== body.visitorToken) return json(res, 403, { error: "forbidden" });
db.prepare("UPDATE conversations SET visitor_seen_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversation.id);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "conversation:seen", conversation: formatConversationSummary(updated) });
return json(res, 200, { ok: true, conversation: formatConversationSummary(updated) });
}
async function createAdminMessage(req, res, url) {
const body = await readJson(req, 8_000_000);
if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" });
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
const updated = await createOperatorMessage(conversation, {
message: body.message,
attachments: body.attachments || [],
senderName: req.adminUser.chatNick || req.adminUser.displayName || req.adminUser.username
});
return json(res, 201, conversationDetails(updated));
}
async function createOperatorMessage(conversation, body) {
const messageId = insertMessage(conversation.id, "admin", body.senderName || "Operator", String(body.message || "").trim());
saveAttachments(conversation.id, messageId, body.attachments || []);
await translateStoredMessage(conversation.id, messageId, "admin", body);
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
markConversationSeen(conversation.id);
const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
notifyTelegramOperatorMessage(updated, messageId).catch((error) => console.error("telegram_operator_notify_failed", error.message));
return updated;
}
function deleteAttachment(res, url) {
const attachmentId = Number(url.pathname.split("/").pop());
const attachment = db.prepare(`
SELECT a.*, c.public_id
FROM attachments a
JOIN conversations c ON c.id = a.conversation_id
WHERE a.id = ?
`).get(attachmentId);
if (!attachment) return json(res, 404, { error: "attachment_not_found" });
db.prepare("DELETE FROM attachments WHERE id = ?").run(attachmentId);
if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) {
fs.rmSync(attachment.storage_path, { force: true });
}
const conversation = findConversation(attachment.public_id);
if (conversation) publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
return json(res, 200, { ok: true });
}
async function visitorTyping(req, res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
publishConversation(conversation, { type: "typing", actor: "visitor", conversationId: conversation.public_id });
return json(res, 200, { ok: true });
}
async function adminTyping(req, res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
touchOperatorPresence(conversation.public_id, req.adminUser);
publishConversation(conversation, {
type: "typing",
actor: "admin",
conversationId: conversation.public_id,
username: req.adminUser.username,
name: operatorDisplayName(req.adminUser)
});
publish("admin", {
type: "operator:presence",
conversationId: conversation.public_id,
operators: currentOperatorPresence(conversation.public_id)
});
return json(res, 200, { ok: true });
}
async function adminPresence(req, res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
touchOperatorPresence(conversation.public_id, req.adminUser);
const operators = currentOperatorPresence(conversation.public_id);
publish("admin", { type: "operator:presence", conversationId: conversation.public_id, operators });
return json(res, 200, { ok: true, operators });
}
function touchOperatorPresence(conversationId, adminUser) {
cleanupOperatorPresence();
const username = adminUser.username || "operator";
operatorPresence.set(`${conversationId}:${username}`, {
conversationId,
username,
name: operatorDisplayName(adminUser),
lastSeenAt: Date.now()
});
}
function operatorDisplayName(adminUser = {}) {
return adminUser.chatNick || adminUser.displayName || adminUser.username || "Operator";
}
function currentOperatorPresence(conversationId) {
cleanupOperatorPresence();
return [...operatorPresence.values()]
.filter((item) => item.conversationId === conversationId)
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => ({
username: item.username,
name: item.name,
lastSeenAt: new Date(item.lastSeenAt).toISOString()
}));
}
function cleanupOperatorPresence() {
const now = Date.now();
for (const [key, item] of operatorPresence.entries()) {
if (now - item.lastSeenAt > 45_000) operatorPresence.delete(key);
}
}
function eventStream(req, res, url, channel) {
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": req.headers.origin || "*",
"Access-Control-Allow-Credentials": "true"
});
res.write(`event: hello\ndata: ${JSON.stringify({ ok: true })}\n\n`);
const client = { res };
if (!streams.has(key)) streams.set(key, new Set());
streams.get(key).add(client);
req.on("close", () => streams.get(key)?.delete(client));
}
function publishConversation(conversation, event) {
publish("admin", event);
publish(`conversation:${conversation.public_id}`, event);
}
function publish(key, event) {
const listeners = streams.get(key);
if (!listeners) return;
for (const client of listeners) {
client.res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
}
}
function insertMessage(conversationId, senderType, senderName, body, clientMessageId = null) {
return db.prepare(`
INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body, translation_status)
VALUES (?, ?, ?, ?, ?, ?)
`).run(conversationId, senderType, senderName, clientMessageId, body, body ? "pending" : "skipped").lastInsertRowid;
}
function messageExists(conversationId, clientMessageId) {
return Boolean(db.prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND client_message_id = ?").get(conversationId, clientMessageId));
}
function findConversationByClientMessage(siteId, visitorToken, clientMessageId) {
if (!clientMessageId) return null;
const row = db.prepare(`
SELECT c.public_id
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.site_id = ? AND c.visitor_token = ? AND m.client_message_id = ?
ORDER BY m.id DESC
LIMIT 1
`).get(siteId, visitorToken, clientMessageId);
return row ? findConversation(row.public_id) : null;
}
function normalizeClientMessageId(value) {
const text = String(value || "").trim();
return /^[a-zA-Z0-9_-]{8,80}$/.test(text) ? text : null;
}
async function translateStoredMessage(conversationId, messageId, senderType, body) {
const message = db.prepare("SELECT * FROM messages WHERE id = ? AND conversation_id = ?").get(messageId, conversationId);
if (!message || !message.body.trim()) return markMessageTranslation(messageId, null, null, null, "skipped", null);
const conversation = findConversationById(conversationId);
const settings = getTranslationSettings();
if (!settings.enabled) return markMessageTranslation(messageId, null, null, null, "disabled", null);
if (!settings.openaiApiKey) return markMessageTranslation(messageId, null, null, null, "missing_key", null);
const targetLanguage = senderType === "visitor"
? settings.operatorLanguage
: normalizeLanguage(conversation.customer_language || conversation.language || body.language || settings.operatorLanguage);
if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped", null);
try {
const result = await translateText(message.body, targetLanguage, settings.model, settings.openaiApiKey);
const detectedSourceLanguage = normalizeDetectedLanguage(result.sourceLanguage);
const sourceLanguage = senderType === "visitor"
? stableVisitorLanguage(conversation, message.body, detectedSourceLanguage)
: detectedSourceLanguage;
const translatedText = String(result.translatedText || "").trim();
const translatedLanguage = normalizeDetectedLanguage(result.translatedLanguage || targetLanguage);
const sameLanguage = sourceLanguage && translatedLanguage && sourceLanguage === translatedLanguage;
if (senderType === "visitor" && sourceLanguage) {
db.prepare("UPDATE conversations SET customer_language = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sourceLanguage, conversationId);
}
if (!translatedText || sameLanguage) {
return markMessageTranslation(messageId, sourceLanguage, null, translatedLanguage, sameLanguage ? "same_language" : "empty", null);
}
markMessageTranslation(messageId, sourceLanguage, translatedText, translatedLanguage, "translated", null);
} catch (error) {
console.error("translation_failed", error.message);
markMessageTranslation(messageId, null, null, targetLanguage, classifyOpenAiError(error.message), error.message);
}
}
function markMessageTranslation(messageId, sourceLanguage, translatedBody, translatedLanguage, status, error) {
db.prepare(`
UPDATE messages
SET original_language = ?,
translated_body = ?,
translated_language = ?,
translation_status = ?,
translation_error = ?
WHERE id = ?
`).run(sourceLanguage, translatedBody, translatedLanguage, status, truncateText(error, 500), messageId);
}
async function translateText(text, targetLanguage, model, apiKey) {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model,
input: [
{
role: "system",
content: "You translate live chat messages for an e-commerce support team. Return only compact JSON with keys sourceLanguage, translatedLanguage, translatedText. Preserve meaning, product terms, numbers, names, URLs, and tone. Do not answer the customer. If the source language is unclear because the text is a greeting, product name, SKU, brand, color, URL, or otherwise language-neutral, set sourceLanguage to unknown."
},
{
role: "user",
content: JSON.stringify({
targetLanguage,
text
})
}
]
})
});
if (!response.ok) {
const openAiError = await openAiErrorFromResponse(response);
throw new Error(openAiError.message);
}
const data = await response.json();
return parseTranslationJson(outputTextFromResponse(data));
}
async function openAiErrorFromResponse(response) {
const errorBody = await response.text().catch(() => "");
let message = errorBody;
let code = `openai_http_${response.status}`;
try {
const parsed = JSON.parse(errorBody);
message = parsed?.error?.message || message;
code = parsed?.error?.code || parsed?.error?.type || code;
} catch {}
const classifiedCode = classifyOpenAiError(`${code} ${message}`);
return {
code: classifiedCode,
message: `OpenAI HTTP ${response.status}: ${message || response.statusText || classifiedCode}`
};
}
function classifyOpenAiError(value) {
const text = String(value || "").toLowerCase();
if (text.includes("insufficient_quota") || text.includes("exceeded your current quota") || text.includes("billing")) return "quota_exceeded";
if (text.includes("invalid_api_key") || text.includes("incorrect api key") || text.includes("unauthorized") || text.includes("401")) return "invalid_key";
if (text.includes("rate_limit") || text.includes("too many requests")) return "rate_limited";
if (text.includes("model") && (text.includes("not found") || text.includes("does not exist"))) return "bad_model";
return "failed";
}
function parseTranslationJson(value) {
try {
const text = String(value || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
const parsed = JSON.parse(text);
return {
sourceLanguage: parsed.sourceLanguage,
translatedLanguage: parsed.translatedLanguage,
translatedText: parsed.translatedText
};
} catch {
throw new Error("OpenAI translation response was not valid JSON");
}
}
function outputTextFromResponse(data) {
if (data.output_text) return data.output_text;
const chunks = [];
for (const item of data.output || []) {
for (const content of item.content || []) {
if (content.type === "output_text" && content.text) chunks.push(content.text);
if (content.type === "text" && content.text) chunks.push(content.text);
}
}
return chunks.join("");
}
async function notifyTelegramVisitorMessage(conversation, messageId) {
const settings = getTelegramSettings();
if (!settings.enabled || !settings.botToken || !settings.chatId) return;
const message = db.prepare("SELECT * FROM messages WHERE id = ? AND conversation_id = ?").get(messageId, conversation.id);
if (!message || message.sender_type !== "visitor") return;
const details = conversationDetails(findConversation(conversation.public_id));
const attachmentCount = details.messages.find((item) => item.id === message.id)?.attachments?.length || 0;
const sent = await telegramSendMessage(settings, {
text: telegramVisitorMessageText(details.conversation, message, attachmentCount),
replyMarkup: {
inline_keyboard: [[
{ text: "Reply", callback_data: `reply:${conversation.public_id}` },
{ text: "Otevrit BO", url: `${PUBLIC_BASE_URL}/admin` }
]]
}
});
const telegramMessageId = sent?.result?.message_id;
const telegramChatId = String(sent?.result?.chat?.id || settings.chatId);
if (!telegramMessageId) return;
db.prepare(`
INSERT OR REPLACE INTO telegram_message_links
(site_id, conversation_id, telegram_chat_id, telegram_message_id, source_message_id)
VALUES (?, ?, ?, ?, ?)
`).run(conversation.site_id, conversation.id, telegramChatId, telegramMessageId, message.id);
}
async function notifyTelegramOperatorMessage(conversation, messageId) {
const settings = getTelegramSettings();
if (!settings.enabled || !settings.botToken || !settings.chatId) return;
const message = db.prepare("SELECT * FROM messages WHERE id = ? AND conversation_id = ?").get(messageId, conversation.id);
if (!message || message.sender_type !== "admin") return;
const details = conversationDetails(findConversation(conversation.public_id));
const attachmentCount = details.messages.find((item) => item.id === message.id)?.attachments?.length || 0;
await telegramSendMessage(settings, {
text: telegramOperatorMessageText(details.conversation, message, attachmentCount),
disableNotification: true
});
}
async function handleTelegramUpdate(update, settings) {
if (update?.callback_query) {
await handleTelegramCallback(update.callback_query, settings);
return;
}
const message = update?.message;
if (!message || !message.text) return;
const chatId = String(message.chat?.id || "");
if (String(settings.chatId) && chatId !== String(settings.chatId)) return;
let link = null;
if (message.reply_to_message) {
const replyToMessageId = Number(message.reply_to_message.message_id);
link = telegramMessageLink(chatId, replyToMessageId);
}
if (!link) link = telegramReplyContext(chatId, message.from?.id);
if (!link) {
if (message.reply_to_message) {
await telegramSendMessage(settings, { chatId, text: "MaalFlows: odpoved nepatri k zadne aktivni notifikaci." });
}
return;
}
const conversation = findConversation(link.public_id);
if (!conversation) {
await telegramSendMessage(settings, { chatId, text: "MaalFlows: konverzace uz neexistuje." });
return;
}
await createOperatorMessage(conversation, {
message: message.text,
attachments: [],
senderName: settings.operatorName
});
clearTelegramReplyContext(chatId, message.from?.id);
}
async function handleTelegramCallback(callbackQuery, settings) {
const data = String(callbackQuery?.data || "");
if (!data.startsWith("reply:")) return;
const chatId = String(callbackQuery.message?.chat?.id || "");
if (String(settings.chatId) && chatId !== String(settings.chatId)) return;
const conversation = findConversation(data.slice("reply:".length));
await telegramAnswerCallback(settings, callbackQuery.id, conversation ? "Napis odpoved." : "Konverzace nenalezena.");
if (!conversation) return;
setTelegramReplyContext(chatId, callbackQuery.from?.id, conversation);
const sent = await telegramSendMessage(settings, {
chatId,
text: "Odpovez na tuto zpravu. Text poslu zakaznikovi:",
replyMarkup: { force_reply: true }
});
const promptMessageId = sent?.result?.message_id;
if (!promptMessageId) return;
db.prepare(`
INSERT OR REPLACE INTO telegram_message_links
(site_id, conversation_id, telegram_chat_id, telegram_message_id, source_message_id)
VALUES (?, ?, ?, ?, ?)
`).run(conversation.site_id, conversation.id, chatId, promptMessageId, null);
}
function telegramMessageLink(chatId, messageId) {
return db.prepare(`
SELECT l.*, c.public_id
FROM telegram_message_links l
JOIN conversations c ON c.id = l.conversation_id
WHERE l.telegram_chat_id = ? AND l.telegram_message_id = ?
LIMIT 1
`).get(chatId, messageId);
}
function setTelegramReplyContext(chatId, telegramUserId, conversation) {
if (!telegramUserId) return;
cleanupTelegramReplyContexts();
telegramReplyContexts.set(`${chatId}:${telegramUserId}`, {
public_id: conversation.public_id,
expiresAt: Date.now() + 5 * 60_000
});
}
function telegramReplyContext(chatId, telegramUserId) {
if (!telegramUserId) return null;
cleanupTelegramReplyContexts();
const context = telegramReplyContexts.get(`${chatId}:${telegramUserId}`);
return context ? { public_id: context.public_id } : null;
}
function clearTelegramReplyContext(chatId, telegramUserId) {
if (!telegramUserId) return;
telegramReplyContexts.delete(`${chatId}:${telegramUserId}`);
}
function cleanupTelegramReplyContexts() {
const now = Date.now();
for (const [key, context] of telegramReplyContexts.entries()) {
if (now > context.expiresAt) telegramReplyContexts.delete(key);
}
}
async function telegramSendMessage(settings, options) {
const token = settings.botToken;
const chatId = options.chatId || settings.chatId;
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text: options.text,
disable_web_page_preview: true,
disable_notification: Boolean(options.disableNotification),
reply_markup: options.replyMarkup
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.description || `Telegram HTTP ${response.status}`);
}
return data;
}
async function telegramAnswerCallback(settings, callbackQueryId, text) {
if (!callbackQueryId) return;
const response = await fetch(`https://api.telegram.org/bot${settings.botToken}/answerCallbackQuery`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
callback_query_id: callbackQueryId,
text
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.description || `Telegram callback HTTP ${response.status}`);
}
return data;
}
async function telegramSetWebhook(settings) {
const response = await fetch(`https://api.telegram.org/bot${settings.botToken}/setWebhook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: telegramWebhookUrl(settings) })
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.description || `Telegram webhook HTTP ${response.status}`);
}
return data;
}
function telegramWebhookUrl(settings) {
return `${PUBLIC_BASE_URL}/api/telegram/webhook/${settings.webhookSecret}`;
}
function telegramVisitorMessageText(conversation, message, attachmentCount) {
const heading = `MaalFlows: ${[conversation.countryFlag, conversation.visitorLabel || "Navstevnik"].filter(Boolean).join(" ")}`;
const lines = [
heading,
conversation.currentUrl ? `Stranka: ${shortText(conversation.currentUrl, 180)}` : "",
"",
message.body ? `Zprava: ${message.body}` : (attachmentCount ? "Zprava: (poslana fotka)" : ""),
message.translated_body ? `\nPreklad: ${message.translated_body}` : "",
attachmentCount ? `\nPrilohy: ${attachmentCount} fotka/fotek` : ""
];
return lines.filter((line) => line !== "").join("\n");
}
function telegramOperatorMessageText(conversation, message, attachmentCount) {
const lines = [
`MaalFlows: ${message.sender_name || "Operator"} odpovedel`,
`Zakaznik: ${[conversation.countryFlag, conversation.visitorLabel || "Navstevnik"].filter(Boolean).join(" ")}`,
"",
message.body ? `Zprava: ${message.body}` : (attachmentCount ? "Zprava: (poslana fotka)" : ""),
message.translated_body ? `\nPreklad pro zakaznika: ${message.translated_body}` : "",
attachmentCount ? `\nPrilohy: ${attachmentCount} fotka/fotek` : ""
];
return lines.filter((line) => line !== "").join("\n");
}
function shortText(value, maxLength) {
const text = String(value || "").replace(/\s+/g, " ").trim();
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
function mergeSiteSettings(currentSettings, incomingSettings) {
const settings = { ...currentSettings, ...incomingSettings };
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage || currentSettings.widgetLanguage || {});
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || currentSettings.widgetCopy || {}, settings);
const currentTranslations = currentSettings.translations || {};
const incomingTranslations = incomingSettings.translations || {};
settings.translations = {
...currentTranslations,
...incomingTranslations
};
if (!incomingTranslations.openaiApiKey) {
settings.translations.openaiApiKey = currentTranslations.openaiApiKey || "";
}
if (isMaskedApiKey(incomingTranslations.openaiApiKey)) {
settings.translations.openaiApiKey = currentTranslations.openaiApiKey || "";
}
settings.translations.openaiApiKey = normalizeOpenAiApiKey(settings.translations.openaiApiKey);
delete settings.translations.hasOpenaiApiKey;
delete settings.translations.openaiApiKeyMasked;
const currentTelegram = currentSettings.telegram || {};
const incomingTelegram = incomingSettings.telegram || {};
settings.telegram = {
...currentTelegram,
...incomingTelegram
};
if (!incomingTelegram.botToken || isMaskedApiKey(incomingTelegram.botToken)) {
settings.telegram.botToken = currentTelegram.botToken || "";
}
settings.telegram.botToken = normalizeSecret(settings.telegram.botToken);
settings.telegram.chatId = String(settings.telegram.chatId || "").trim();
settings.telegram.webhookSecret = normalizeWebhookSecret(settings.telegram.webhookSecret) || normalizeWebhookSecret(currentTelegram.webhookSecret) || id("tgsec");
settings.telegram.operatorName = normalizeDisplayName(settings.telegram.operatorName) || "Alex";
delete settings.telegram.hasBotToken;
delete settings.telegram.botTokenMasked;
delete settings.telegram.webhookUrl;
settings.adminPresence = normalizeAdminPresenceSettings(settings.adminPresence || currentSettings.adminPresence || {});
return settings;
}
function normalizeAdminPresenceSettings(value = {}) {
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10);
return {
activeSeconds,
offlineSeconds,
clusterBotFilterEnabled: value.clusterBotFilterEnabled !== false,
clusterBotCount: clampNumber(value.clusterBotCount, 3, 500, 20),
clusterBotWindowSeconds: clampNumber(value.clusterBotWindowSeconds, 10, 3600, 60)
};
}
function adminPresenceSettings() {
const site = getDefaultSite();
const settings = parseJson(site.settings_json, defaultSettings());
return normalizeAdminPresenceSettings(settings.adminPresence || {});
}
function clampNumber(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.round(number)));
}
function normalizeWidgetLanguageSettings(value) {
const defaults = defaultSettings().widgetLanguage;
const settings = { ...defaults, ...(value || {}) };
const mode = settings.mode === "fixed" || settings.mode === "default" ? "default" : "auto";
const defaultLanguage = allowedWidgetLanguage(settings.defaultLanguage || settings.fixedLanguage || settings.fallbackLanguage) || defaults.defaultLanguage;
return {
mode,
defaultLanguage
};
}
function normalizeWidgetCopy(value, settings = {}) {
const copy = { ...(value || {}) };
const legacyLanguages = new Set([
...Object.keys(settings.title || {}),
...Object.keys(settings.intro || {}),
...Object.keys(settings.offlineIntro || {}),
...Object.keys(settings.placeholder || {}),
...Object.keys(settings.sendLabel || {})
]);
for (const language of legacyLanguages) {
const lang = allowedWidgetLanguage(language);
if (!lang) continue;
const legacyCopy = {
...(copy[lang] || {}),
title: settings.title?.[lang],
intro: settings.intro?.[lang],
offlineIntro: settings.offlineIntro?.[lang],
placeholder: settings.placeholder?.[lang],
sendLabel: settings.sendLabel?.[lang]
};
if (lang !== "cs" && lang !== "en" && widgetCopyLooksUntranslated(defaultWidgetCopy("cs"), normalizeWidgetCopyEntry(legacyCopy, lang))) continue;
copy[lang] = legacyCopy;
}
copy.cs = normalizeWidgetCopyEntry(copy.cs || defaultWidgetCopy("cs"), "cs");
copy.en = normalizeWidgetCopyEntry(copy.en || defaultWidgetCopy("en"), "en");
for (const language of Object.keys(copy)) {
const lang = allowedWidgetLanguage(language);
if (!lang) delete copy[language];
else copy[lang] = normalizeWidgetCopyEntry(copy[language], lang);
}
return copy;
}
function normalizeWidgetCopyEntry(value, language) {
const defaults = defaultWidgetCopy(language === "en" ? "en" : "cs");
const legacyCs = language === "cs" ? legacyCzechWidgetCopy() : null;
const copy = {};
for (const field of WIDGET_COPY_FIELDS) {
copy[field] = String(value?.[field] || defaults[field] || "").trim();
if (legacyCs && copy[field] === legacyCs[field]) copy[field] = defaults[field];
}
return copy;
}
function getTranslationSettings() {
const site = getDefaultSite();
const settings = parseJson(site.settings_json, defaultSettings());
const translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
return {
enabled: Boolean(translations.enabled),
operatorLanguage: normalizeLanguage(translations.operatorLanguage) || "cs",
model: String(translations.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL,
openaiApiKey: normalizeOpenAiApiKey(translations.openaiApiKey) || OPENAI_API_KEY
};
}
function getTelegramSettings() {
const site = getDefaultSite();
const settings = parseJson(site.settings_json, defaultSettings());
const telegram = { ...defaultSettings().telegram, ...(settings.telegram || {}) };
return {
enabled: Boolean(telegram.enabled),
botToken: normalizeSecret(telegram.botToken) || TELEGRAM_BOT_TOKEN,
chatId: String(telegram.chatId || TELEGRAM_CHAT_ID || "").trim(),
webhookSecret: normalizeWebhookSecret(telegram.webhookSecret) || TELEGRAM_WEBHOOK_SECRET,
operatorName: normalizeDisplayName(telegram.operatorName) || "Alex"
};
}
function widgetLanguageForRequest(site, url) {
const settings = parseJson(site.settings_json, defaultSettings());
const languageSettings = normalizeWidgetLanguageSettings(settings.widgetLanguage);
if (languageSettings.mode === "default") return languageSettings.defaultLanguage;
return allowedWidgetLanguage(url.searchParams.get("lang")) || languageSettings.defaultLanguage;
}
async function widgetCopyForLanguage(site, requestedLanguage) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
const language = allowedWidgetLanguage(requestedLanguage) || settings.widgetLanguage.defaultLanguage;
if (settings.widgetCopy[language]) return { language, copy: settings.widgetCopy[language] };
const fallbackLanguage = settings.widgetLanguage.defaultLanguage;
const fallbackCopy = settings.widgetCopy[fallbackLanguage] || defaultWidgetCopy("cs");
const translated = await translateWidgetCopyOnce(fallbackCopy, fallbackLanguage, language);
if (!translated) return { language: fallbackLanguage, copy: fallbackCopy };
settings.widgetCopy[language] = translated;
syncLegacyWidgetCopy(settings);
db.prepare("UPDATE sites SET settings_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(JSON.stringify(settings), site.id);
return { language, copy: translated };
}
async function translateWidgetCopyOnce(sourceCopy, sourceLanguage, targetLanguage) {
const settings = getTranslationSettings();
if (!settings.enabled || !settings.openaiApiKey || !targetLanguage || sourceLanguage === targetLanguage) return null;
try {
const translatedCopy = {};
for (const field of WIDGET_COPY_FIELDS) {
const value = String(sourceCopy[field] || "").trim();
if (!value) {
translatedCopy[field] = "";
continue;
}
const result = await translateText(value, targetLanguage, settings.model, settings.openaiApiKey);
translatedCopy[field] = String(result.translatedText || value).trim();
}
const translated = normalizeWidgetCopyEntry(translatedCopy, targetLanguage);
if (widgetCopyLooksUntranslated(sourceCopy, translated)) return null;
return translated;
} catch (error) {
console.error("widget_copy_translation_failed", targetLanguage, error.message);
return null;
}
}
async function syncChangedWidgetCopyTranslations(previousSettings, nextSettings) {
const languageSettings = normalizeWidgetLanguageSettings(nextSettings.widgetLanguage);
const defaultLanguage = languageSettings.defaultLanguage;
const previousCopy = normalizeWidgetCopy(previousSettings.widgetCopy || {}, previousSettings);
const nextCopy = normalizeWidgetCopy(nextSettings.widgetCopy || {}, nextSettings);
const previousDefaultCopy = previousCopy[defaultLanguage] || {};
const nextDefaultCopy = nextCopy[defaultLanguage] || {};
const changedFields = WIDGET_COPY_FIELDS.filter((field) =>
String(previousDefaultCopy[field] || "").trim() !== String(nextDefaultCopy[field] || "").trim()
);
if (!changedFields.length) return { changedFields: [], languages: [], translated: false, errors: [] };
const translationSettings = {
...defaultSettings().translations,
...(nextSettings.translations || {})
};
const apiKey = normalizeOpenAiApiKey(translationSettings.openaiApiKey) || OPENAI_API_KEY;
const model = String(translationSettings.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL;
const languages = Object.keys(nextCopy)
.map(allowedWidgetLanguage)
.filter((language, index, list) => language && language !== defaultLanguage && list.indexOf(language) === index);
if (!languages.length) return { changedFields, languages: [], translated: false, errors: [] };
if (!translationSettings.enabled || !apiKey) {
return { changedFields, languages, translated: false, errors: languages.map((language) => ({ language, error: "missing_ai_settings" })) };
}
nextSettings.widgetCopy = nextCopy;
const errors = [];
let translated = 0;
for (const language of languages) {
nextSettings.widgetCopy[language] = normalizeWidgetCopyEntry(nextSettings.widgetCopy[language] || {}, language);
for (const field of changedFields) {
const value = String(nextDefaultCopy[field] || "").trim();
if (!value) {
nextSettings.widgetCopy[language][field] = "";
translated += 1;
continue;
}
try {
const result = await translateText(value, language, model, apiKey);
nextSettings.widgetCopy[language][field] = String(result.translatedText || value).trim();
translated += 1;
} catch (error) {
console.error("widget_copy_sync_failed", language, field, error.message);
errors.push({ language, field, error: classifyOpenAiError(error.message), message: error.message });
}
}
}
return { changedFields, languages, translated: translated > 0, errors };
}
function widgetCopyLooksUntranslated(sourceCopy, translatedCopy) {
const importantFields = ["title", "intro", "offlineIntro", "placeholder", "sendLabel"];
return importantFields.every((field) => String(sourceCopy[field] || "").trim() === String(translatedCopy[field] || "").trim());
}
function syncLegacyWidgetCopy(settings) {
for (const language of Object.keys(settings.widgetCopy || {})) {
const copy = settings.widgetCopy[language];
settings.title = { ...(settings.title || {}), [language]: copy.title };
settings.intro = { ...(settings.intro || {}), [language]: copy.intro };
settings.offlineIntro = { ...(settings.offlineIntro || {}), [language]: copy.offlineIntro };
settings.placeholder = { ...(settings.placeholder || {}), [language]: copy.placeholder };
settings.sendLabel = { ...(settings.sendLabel || {}), [language]: copy.sendLabel };
}
}
function allowedWidgetLanguage(value) {
const language = normalizeLanguage(value);
return EUROPEAN_WIDGET_LANGUAGES.has(language) ? language : "";
}
function normalizeOpenAiApiKey(value) {
const text = String(value || "").trim();
if (!text || isMaskedApiKey(text)) return "";
return text;
}
function isMaskedApiKey(value) {
return String(value || "").includes("...");
}
function maskApiKey(value) {
const text = normalizeOpenAiApiKey(value);
if (!text) return "";
return `${text.slice(0, 7)}...${text.slice(-4)}`;
}
function normalizeSecret(value) {
const text = String(value || "").trim();
if (!text || isMaskedApiKey(text)) return "";
return text;
}
function maskSecret(value) {
const text = normalizeSecret(value);
if (!text) return "";
if (text.length <= 10) return `${text.slice(0, 2)}...${text.slice(-2)}`;
return `${text.slice(0, 6)}...${text.slice(-4)}`;
}
function normalizeWebhookSecret(value) {
const text = String(value || "").trim();
return /^[a-zA-Z0-9_-]{12,120}$/.test(text) ? text : "";
}
function fallbackTranslationModels() {
return [
OPENAI_TRANSLATION_MODEL,
"gpt-5.1",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1-mini",
"gpt-4.1-nano"
].filter(Boolean);
}
function isUsableTextModel(id) {
return /^(gpt-|o[134])/.test(id)
&& !/audio|realtime|transcribe|tts|image|search|embedding|moderation|codex/i.test(id);
}
function normalizeLanguage(value) {
const text = String(value || "").trim().toLowerCase().replace("_", "-");
if (!text) return "";
return text.split("-")[0].slice(0, 8);
}
function normalizeDetectedLanguage(value) {
const language = normalizeLanguage(value);
return ["unknown", "und", "auto", "n/a", "none"].includes(language) ? "" : language;
}
function stableVisitorLanguage(conversation, text, detectedLanguage) {
const currentLanguage = normalizeDetectedLanguage(conversation?.customer_language);
const browserLanguage = normalizeDetectedLanguage(conversation?.language);
if (!currentLanguage) {
if (browserLanguage && weakLanguageEvidence(text)) return browserLanguage;
return detectedLanguage;
}
if (!detectedLanguage || detectedLanguage === currentLanguage) return currentLanguage;
return weakLanguageEvidence(text) ? currentLanguage : detectedLanguage;
}
function weakLanguageEvidence(value) {
const text = String(value || "").trim();
if (!text) return true;
const words = text.match(/[\p{L}\p{N}][\p{L}\p{N}'-]*/gu) || [];
const lowerWords = words.map((word) => word.toLowerCase());
if (!words.length) return true;
if (hasStrongLanguageEvidence(text, lowerWords)) return false;
const compact = lowerWords.join(" ");
const weakPhrases = new Set([
"hi", "hello", "hey", "ok", "okay", "yes", "no", "thanks", "thank you",
"hallo", "servus", "danke", "ja", "nein", "bitte"
]);
if (weakPhrases.has(compact)) return true;
if (words.length <= 2) return true;
const startsLikeProductName = /^[\p{Lu}\p{N}]/u.test(words[0]);
const sentencePunctuation = /[?!.]/.test(text);
return words.length <= 6 && startsLikeProductName && !sentencePunctuation;
}
function hasStrongLanguageEvidence(text, lowerWords) {
const strongWords = new Set([
"i", "you", "we", "need", "want", "would", "could", "please", "what", "which", "size", "have", "has", "is", "are",
"ich", "sie", "wir", "brauche", "benotige", "möchte", "mochte", "bitte", "welche", "welcher", "welches", "größe", "groesse", "hat", "haben", "ist", "sind", "wie", "was", "kann"
]);
const hits = lowerWords.filter((word) => strongWords.has(word)).length;
return hits >= 2 || (hits >= 1 && /[?]/.test(text));
}
function truncateText(value, maxLength) {
const text = String(value || "").trim();
if (!text) return null;
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
}
function saveAttachments(conversationId, messageId, attachments) {
for (const attachment of attachments.slice(0, 3)) {
if (!attachment.dataBase64 || !attachment.name) continue;
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
if (!isAllowedImageAttachment(attachment, buffer)) continue;
const safeName = path.basename(attachment.name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
const filename = `${Date.now()}-${crypto.randomBytes(6).toString("hex")}-${safeName}`;
const storagePath = path.join(UPLOAD_DIR, filename);
fs.writeFileSync(storagePath, buffer);
db.prepare(`
INSERT INTO attachments (conversation_id, message_id, original_name, mime_type, byte_size, storage_path, public_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
conversationId,
messageId,
attachment.name,
attachment.type || "application/octet-stream",
buffer.length,
storagePath,
`/uploads/${filename}`
);
}
}
function bumpConversation(conversationId, status) {
db.prepare(`
UPDATE conversations
SET status = ?, last_message_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(status, conversationId);
}
function updateVisitorContext(conversationId, body) {
db.prepare(`
UPDATE conversations
SET current_url = COALESCE(?, current_url),
referrer = COALESCE(?, referrer),
browsing_history_json = COALESCE(?, browsing_history_json),
device_json = COALESCE(?, device_json),
language = COALESCE(?, language),
timezone = COALESCE(?, timezone),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
nullish(body.currentUrl),
nullish(body.referrer),
body.browsingHistory ? JSON.stringify(body.browsingHistory) : null,
body.device ? JSON.stringify(body.device) : null,
nullish(body.language),
nullish(body.timezone),
conversationId
);
}
function currentVisitors() {
const presence = adminPresenceSettings();
const rows = db.prepare(`
SELECT vs.*, s.site_key, vp.display_name AS visitor_display_name,
(SELECT c.public_id FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token ORDER BY datetime(c.last_message_at) DESC, c.id DESC LIMIT 1) AS last_conversation_id,
(SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
FROM visitor_sessions vs
JOIN sites s ON s.id = vs.site_id
LEFT JOIN visitor_profiles vp ON vp.site_id = vs.site_id AND vp.visitor_token = vs.visitor_token
WHERE datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) >= datetime('now', '-${presence.offlineSeconds} seconds')
ORDER BY datetime(vs.first_seen_at) ASC, vs.id ASC
LIMIT 100
`).all();
const clusters = suspiciousVisitorClusters(recentVisitorClusterRows(presence), presence);
return rows.map((row) => formatVisitorSession(row, presence, clusters));
}
function recentVisitorClusterRows(settings) {
if (!settings.clusterBotFilterEnabled) return [];
const lookbackSeconds = Math.max(settings.offlineSeconds, settings.clusterBotWindowSeconds * 4, 600);
return db.prepare(`
SELECT vs.*, s.site_key, vp.display_name AS visitor_display_name,
(SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
FROM visitor_sessions vs
JOIN sites s ON s.id = vs.site_id
LEFT JOIN visitor_profiles vp ON vp.site_id = vs.site_id AND vp.visitor_token = vs.visitor_token
WHERE datetime(COALESCE(vs.last_activity_at, vs.last_seen_at, vs.first_seen_at)) >= datetime('now', '-${lookbackSeconds} seconds')
OR datetime(vs.first_seen_at) >= datetime('now', '-${lookbackSeconds} seconds')
ORDER BY datetime(vs.first_seen_at) ASC, vs.id ASC
LIMIT 3000
`).all();
}
function formatVisitorSession(row, thresholds = adminPresenceSettings(), clusters = new Map()) {
const history = parseJson(row.browsing_history_json, []);
const device = parseJson(row.device_json, {});
const pageCountValue = new Set(history.map((item) => item.url).filter(Boolean)).size;
const sessionSecondsValue = sessionSeconds(row.browsing_history_json, row.first_seen_at);
const conversationCountValue = row.visitor_conversation_count || 0;
const bot = botInfo(device.userAgent, {
pageCount: pageCountValue,
visitCount: Math.max(1, history.length),
conversationCount: conversationCountValue,
sessionSeconds: sessionSecondsValue,
referrer: row.referrer
});
const clusterReason = visitorClusterReason(row, device, clusters);
const finalBot = clusterReason && conversationCountValue === 0 ? { isBot: true, reason: clusterReason } : bot;
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
const presence = visitorPresence(row, thresholds);
return {
id: row.visitor_token,
initials: visitorInitials(row.visitor_token),
label: row.visitor_display_name || visitorLabel(row.visitor_token),
defaultLabel: visitorLabel(row.visitor_token),
visitorAlias: row.visitor_display_name || "",
online: presence !== "offline",
presence,
currentUrl: row.current_url,
referrer: row.referrer,
browsingHistory: history,
pageCount: pageCountValue,
visitCount: Math.max(1, history.length),
conversationCount: conversationCountValue,
lastConversationId: row.last_conversation_id,
isBot: finalBot.isBot,
botReason: finalBot.reason,
device,
language: row.language,
timezone: row.timezone,
countryCode,
countryFlag: countryCode ? flagEmoji(countryCode) : "",
sessionSeconds: sessionSecondsValue,
firstSeenAt: row.first_seen_at,
lastActivityAt: row.last_activity_at,
lastSeenAt: row.last_seen_at
};
}
function visitorPresence(row, thresholds = adminPresenceSettings()) {
const lastSeen = parseTimestampMs(row.last_seen_at);
const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at);
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > thresholds.offlineSeconds * 1000) return "offline";
if (!Number.isFinite(lastActivity) || Date.now() - lastActivity > thresholds.offlineSeconds * 1000) return "offline";
return Number.isFinite(lastActivity) && Date.now() - lastActivity <= thresholds.activeSeconds * 1000 ? "active" : "idle";
}
function suspiciousVisitorClusters(rows, settings) {
if (!settings.clusterBotFilterEnabled) return new Map();
const groups = new Map();
for (const row of rows) {
if (Number(row.visitor_conversation_count || 0) > 0) continue;
if (String(row.referrer || "").trim()) continue;
const device = parseJson(row.device_json, {});
const firstSeen = parseTimestampMs(row.first_seen_at);
if (!Number.isFinite(firstSeen)) continue;
for (const key of visitorClusterKeys(row, device)) {
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(firstSeen);
}
}
const suspicious = new Map();
const windowMs = settings.clusterBotWindowSeconds * 1000;
for (const [key, times] of groups.entries()) {
const requiredCount = key.startsWith("device|")
? Math.max(settings.clusterBotCount * 3, settings.clusterBotCount + 20)
: settings.clusterBotCount;
times.sort((a, b) => a - b);
let start = 0;
for (let end = 0; end < times.length; end += 1) {
while (times[end] - times[start] > windowMs) start += 1;
if (end - start + 1 >= requiredCount) {
suspicious.set(key, `cluster ${end - start + 1}/${settings.clusterBotWindowSeconds}s`);
break;
}
}
}
return suspicious;
}
function visitorClusterReason(row, device, clusters) {
for (const key of visitorClusterKeys(row, device)) {
const reason = clusters.get(key);
if (reason) return reason;
}
return "";
}
function visitorClusterKeys(row, device = {}) {
const ip = String(row.ip || "").trim();
const country = String(row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language) || "").toUpperCase();
const platform = String(device.platform || "").trim();
const ua = String(device.userAgent || "").trim();
const browser = ua.includes("Edg/") ? "Edge"
: ua.includes("Chrome/") ? "Chrome"
: ua.includes("Firefox/") ? "Firefox"
: ua.includes("Safari/") ? "Safari"
: ua.slice(0, 80);
const deviceKey = [country, platform, browser].join("|");
const keys = [`device|${deviceKey}`];
if (ip) keys.unshift(`ip|${ip}|${deviceKey}`);
return keys.filter((key) => key.replace(/^(ip\|[^|]*\||device\|)/, "").replace(/\|/g, "").trim());
}
function conversationDetails(conversation) {
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
return {
conversation: formatConversationSummary(conversation),
operators: currentOperatorPresence(conversation.public_id),
messages: messages.map((message) => ({
id: message.id,
senderType: message.sender_type,
senderName: message.sender_name,
body: message.body,
originalLanguage: message.original_language,
translatedBody: message.translated_body,
translatedLanguage: message.translated_language,
translationStatus: message.translation_status,
translationError: message.translation_error,
createdAt: message.created_at,
attachments: attachments
.filter((item) => item.message_id === message.id)
.map(formatAttachment)
}))
};
}
function formatConversationSummary(row) {
return {
id: row.public_id,
siteId: row.site_id,
siteKey: row.site_key,
status: row.status,
visitorToken: row.visitor_token,
customerLanguage: row.customer_language || "",
visitorInitials: visitorInitials(row.visitor_token),
visitorLabel: row.visitor_display_name || visitorLabel(row.visitor_token),
visitorDefaultLabel: visitorLabel(row.visitor_token),
visitorAlias: row.visitor_display_name || "",
visitorOnline: Boolean(row.visitor_online),
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
visitorSeenAt: row.visitor_seen_at,
visitor: {
name: row.visitor_name,
email: row.visitor_email,
phone: row.visitor_phone
},
currentUrl: row.current_url,
referrer: row.referrer,
browsingHistory: parseJson(row.browsing_history_json, []),
device: parseJson(row.device_json, {}),
language: row.language,
timezone: row.timezone,
ip: row.ip,
countryCode: row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language),
countryFlag: (row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language))
? flagEmoji(row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language))
: "",
vpnRisk: Boolean(row.vpn_risk),
lastBody: row.last_body,
messageCount: row.message_count,
imageCount: row.image_count || 0,
hasUnread: Boolean(row.has_unread),
pageCount: pageCount(row.browsing_history_json),
sessionSeconds: sessionSeconds(row.browsing_history_json, row.created_at),
visitorConversationCount: row.visitor_conversation_count || 1,
lastMessageAt: row.last_message_at,
createdAt: row.created_at
};
}
function formatAttachment(row) {
return {
id: row.id,
name: row.original_name,
type: row.mime_type,
size: row.byte_size,
url: row.public_url
};
}
function formatSite(site) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
settings.launcherEffects.shape = normalizeLauncherShape(settings.launcherEffects.shape);
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
syncLegacyWidgetCopy(settings);
settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY;
settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey);
settings.translations.openaiApiKeyMasked = maskApiKey(storedOpenAiKey);
settings.translations.openaiApiKey = "";
settings.telegram = { ...defaultSettings().telegram, ...(settings.telegram || {}) };
const storedTelegramToken = settings.telegram.botToken || TELEGRAM_BOT_TOKEN;
const telegramSecret = normalizeWebhookSecret(settings.telegram.webhookSecret) || TELEGRAM_WEBHOOK_SECRET || "";
settings.telegram.hasBotToken = Boolean(storedTelegramToken);
settings.telegram.botTokenMasked = maskSecret(storedTelegramToken);
settings.telegram.botToken = "";
settings.telegram.webhookSecret = telegramSecret;
settings.telegram.webhookUrl = telegramSecret ? telegramWebhookUrl({ webhookSecret: telegramSecret }) : "";
return {
id: site.id,
siteKey: site.site_key,
name: site.name,
domain: site.domain,
isOnline: Boolean(site.is_online),
settings
};
}
function normalizeLauncherShape(value) {
return ["circle", "bubble", "messenger", "whatsapp"].includes(value) ? value : "bubble";
}
function snippetFor(site) {
const settings = parseJson(site.settings_json, defaultSettings());
const languageSettings = normalizeWidgetLanguageSettings(settings.widgetLanguage);
const snippetLanguage = languageSettings.defaultLanguage || "cs";
return `
`;
}
function getDefaultSite() {
return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get();
}
function getSiteById(id) {
return db.prepare("SELECT * FROM sites WHERE id = ?").get(id);
}
function findConversation(publicId) {
const presence = adminPresenceSettings();
return db.prepare(`
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,
(SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body,
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
EXISTS(
SELECT 1 FROM messages unread
WHERE unread.conversation_id = c.id
AND unread.sender_type = 'visitor'
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
) AS has_unread,
EXISTS(
SELECT 1 FROM visitor_sessions vs
WHERE vs.site_id = c.site_id
AND vs.visitor_token = c.visitor_token
AND datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) >= datetime('now', '-${presence.offlineSeconds} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) < datetime('now', '-${presence.offlineSeconds} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active'
ELSE 'idle'
END
FROM visitor_sessions vs
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 1
), 'offline') AS visitor_presence,
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
FROM conversations c
JOIN sites s ON s.id = c.site_id
LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
WHERE c.public_id = ?
`).get(publicId);
}
function findConversationById(id) {
const row = db.prepare("SELECT public_id FROM conversations WHERE id = ?").get(id);
return row ? findConversation(row.public_id) : null;
}
function markConversationSeen(conversationId) {
db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId);
}
function markConversationUnread(conversationId) {
db.prepare("UPDATE conversations SET admin_seen_at = NULL WHERE id = ?").run(conversationId);
}
function publicIdFrom(url) {
const parts = url.pathname.split("/").filter(Boolean);
return parts[parts.indexOf("conversations") + 1];
}
function servePublic(res, pathname) {
const target = path.join(PUBLIC_DIR, path.basename(pathname));
const type = pathname.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
return file(res, target, type);
}
function serveUpload(res, pathname) {
const target = path.join(UPLOAD_DIR, path.basename(pathname));
const attachment = db.prepare("SELECT mime_type FROM attachments WHERE public_url = ?").get(`/uploads/${path.basename(pathname)}`);
return file(res, target, attachment?.mime_type || "application/octet-stream");
}
function file(res, target, contentType) {
if (!fs.existsSync(target)) return json(res, 404, { error: "not_found" });
res.writeHead(200, { "Content-Type": contentType });
fs.createReadStream(target).pipe(res);
}
function json(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function setCommonHeaders(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
}
function endCors(res) {
res.writeHead(204);
res.end();
}
function readJson(req, limit = 500_000) {
return new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > limit) {
reject(new Error("payload_too_large"));
req.destroy();
}
});
req.on("end", () => {
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function signSession(username) {
const payload = Buffer.from(JSON.stringify({ username, exp: Date.now() + 14 * 86400_000 })).toString("base64url");
const sig = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
function verifySession(token) {
const [payload, sig] = String(token).split(".");
if (!payload || !sig) return null;
const expected = crypto.createHmac("sha256", AUTH_SECRET).update(payload).digest("base64url");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
return data.exp > Date.now() ? data.username : null;
}
function normalizeAdminUsername(value) {
const text = String(value || "").trim().toLowerCase();
return /^[a-z0-9._@-]{3,60}$/.test(text) ? text : "";
}
function normalizeDisplayName(value) {
return String(value || "").trim().slice(0, 80);
}
function validAdminPassword(value) {
const text = String(value || "");
return text.length >= 8 && text.length <= 200;
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const iterations = 120000;
const hash = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256").toString("hex");
return `pbkdf2_sha256$${iterations}$${salt}$${hash}`;
}
function verifyPassword(password, storedHash) {
const [scheme, iterationsText, salt, hash] = String(storedHash || "").split("$");
if (scheme !== "pbkdf2_sha256" || !salt || !hash) return false;
const iterations = Number(iterationsText);
if (!Number.isFinite(iterations)) return false;
const candidate = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256");
const expected = Buffer.from(hash, "hex");
return expected.length === candidate.length && crypto.timingSafeEqual(candidate, expected);
}
function parseCookies(header) {
return Object.fromEntries(header.split(";").filter(Boolean).map((part) => {
const [key, ...value] = part.trim().split("=");
return [key, value.join("=")];
}));
}
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function pageCount(historyJson) {
return new Set(parseJson(historyJson, []).map((item) => item.url).filter(Boolean)).size;
}
function sessionSeconds(historyJson, createdAt) {
const history = parseJson(historyJson, []);
const times = history
.map((item) => Date.parse(item.at))
.filter((value) => Number.isFinite(value));
if (times.length > 1) return Math.max(0, Math.round((Math.max(...times) - Math.min(...times)) / 1000));
const created = Date.parse(`${createdAt}Z`);
return Number.isFinite(created) ? Math.max(0, Math.round((Date.now() - created) / 1000)) : 0;
}
function id(prefix) {
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
}
function nullish(value) {
return value === undefined || value === "" ? null : value;
}
function normalizeDisplayName(value) {
const text = String(value || "").trim().replace(/\s+/g, " ");
return text ? text.slice(0, 80) : null;
}
function timestampOrNow(value) {
const parsed = Date.parse(value || "");
if (!Number.isFinite(parsed)) return new Date().toISOString();
const now = Date.now();
const bounded = Math.min(Math.max(parsed, now - 24 * 60 * 60 * 1000), now);
return new Date(bounded).toISOString();
}
function parseTimestampMs(value) {
if (!value) return NaN;
const text = String(value);
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text;
return Date.parse(normalized);
}
function clientIp(req) {
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
}
function countryFromHeaders(req) {
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
}
function countryFromTimezone(timezone) {
const zones = {
"Europe/Prague": "CZ",
"Europe/Bratislava": "SK",
"Europe/Warsaw": "PL",
"Europe/Berlin": "DE",
"Europe/Vienna": "AT",
"Europe/Budapest": "HU"
};
return zones[String(timezone || "")] || null;
}
function countryFromLanguage(language) {
const match = String(language || "").match(/-([A-Za-z]{2})\b/);
return match ? match[1].toUpperCase() : null;
}
function botInfo(userAgent, activity = {}) {
const ua = String(userAgent || "");
const patterns = [
"googlebot", "bingbot", "slurp", "duckduckbot", "baiduspider", "yandexbot",
"ahrefsbot", "semrushbot", "mj12bot", "dotbot", "petalbot", "bytespider",
"facebookexternalhit", "twitterbot", "linkedinbot", "slackbot", "discordbot",
"whatsapp", "telegrambot", "preview", "crawler", "spider", "headlesschrome",
"phantomjs", "playwright", "puppeteer", "selenium", "cypress", "chrome-lighthouse",
"pagespeed", "lighthouse", "pingdom", "uptimerobot", "curl", "wget",
"python-requests", "aiohttp", "go-http-client", "axios", "httpclient", "java/",
"okhttp", "libwww-perl", "scrapy", "httpx", "node-fetch", "undici"
];
const lower = ua.toLowerCase();
const match = patterns.find((pattern) => lower.includes(pattern));
if (match) return { isBot: true, reason: match };
if (/\bbot\b/i.test(ua)) return { isBot: true, reason: "bot" };
if (!ua.trim()) return { isBot: true, reason: "empty ua" };
if (activity.conversationCount > 0) return { isBot: false, reason: "" };
const pageCount = Number(activity.pageCount || 0);
const visitCount = Number(activity.visitCount || 0);
const sessionSecondsValue = Number(activity.sessionSeconds || 0);
if (pageCount >= 25 && sessionSecondsValue <= 180) return { isBot: true, reason: "fast crawler" };
if (pageCount >= 40) return { isBot: true, reason: "many pages" };
if (visitCount >= 60 && !activity.referrer) return { isBot: true, reason: "no-ref crawler" };
return { isBot: false, reason: "" };
}
function visitorInitials(visitorToken) {
const hash = crypto.createHash("sha1").update(String(visitorToken || "")).digest("hex").toUpperCase();
return hash.slice(0, 2);
}
function visitorLabel(visitorToken) {
return `Navstevnik ${visitorInitials(visitorToken)}`;
}
function flagEmoji(countryCode) {
if (!countryCode || countryCode.length !== 2) return "";
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
}
function isAllowedImageAttachment(attachment, buffer) {
const extension = path.extname(attachment.name || "").toLowerCase();
const type = String(attachment.type || "").toLowerCase();
if (!buffer.length || buffer.length > MAX_ATTACHMENT_BYTES) return false;
if (!ALLOWED_IMAGE_TYPES.has(type) || !ALLOWED_IMAGE_EXTENSIONS.has(extension)) return false;
return hasExpectedImageSignature(type, buffer);
}
function attachmentsAreAllowed(attachments) {
return attachments.every((attachment) => {
if (!attachment.dataBase64 || !attachment.name) return true;
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
return isAllowedImageAttachment(attachment, buffer);
});
}
function hasExpectedImageSignature(type, buffer) {
if (type === "image/jpeg") return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
if (type === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (type === "image/gif") return buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a";
if (type === "image/webp") return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
if (type === "image/avif") return buffer.subarray(4, 12).toString("ascii").includes("ftyp");
return false;
}
function loadDotEnv(filePath) {
if (!fs.existsSync(filePath)) return;
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const index = trimmed.indexOf("=");
if (index === -1) continue;
const key = trimmed.slice(0, index).trim();
const value = trimmed.slice(index + 1).trim();
if (!(key in process.env)) process.env[key] = value;
}
}