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 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 ACTIVE_VISITOR_SECONDS = 90;
const PRESENT_VISITOR_SECONDS = 300;
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 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 === "/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/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);
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 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);
`);
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: {
breathe: true,
ring: true,
hoverLabel: true,
avatar: true
},
notifications: {
pulseOnAdminReply: true,
badgeOnAdminReply: true,
labelOnAdminReply: true,
wiggleOnAdminReply: true
},
translations: {
enabled: false,
operatorLanguage: "cs",
model: OPENAI_TRANSLATION_MODEL,
openaiApiKey: ""
}
};
}
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: "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 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(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} 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);
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) });
}
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 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) });
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) });
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 messageId = insertMessage(conversation.id, "admin", req.adminUser.chatNick || req.adminUser.displayName || req.adminUser.username, 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) });
return json(res, 201, conversationDetails(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" });
publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id });
return json(res, 200, { ok: true });
}
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 sourceLanguage = normalizeLanguage(result.sourceLanguage);
const translatedText = String(result.translatedText || "").trim();
const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage);
const sameLanguage = sourceLanguage && translatedLanguage && sourceLanguage === translatedLanguage;
if (senderType === "visitor" && sourceLanguage) {
db.prepare("UPDATE conversations SET customer_language = COALESCE(?, 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."
},
{
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("");
}
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;
return settings;
}
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 copy = {};
for (const field of WIDGET_COPY_FIELDS) {
copy[field] = String(value?.[field] || defaults[field] || "").trim();
}
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 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;
}
}
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 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 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 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(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 100
`).all();
return rows.map(formatVisitorSession);
}
function formatVisitorSession(row) {
const history = parseJson(row.browsing_history_json, []);
const device = parseJson(row.device_json, {});
const bot = botInfo(device.userAgent);
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
const presence = visitorPresence(row);
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: new Set(history.map((item) => item.url).filter(Boolean)).size,
visitCount: Math.max(1, history.length),
conversationCount: row.visitor_conversation_count || 0,
lastConversationId: row.last_conversation_id,
isBot: bot.isBot,
botReason: bot.reason,
device,
language: row.language,
timezone: row.timezone,
countryCode,
countryFlag: countryCode ? flagEmoji(countryCode) : "",
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
lastActivityAt: row.last_activity_at,
lastSeenAt: row.last_seen_at
};
}
function visitorPresence(row) {
const lastSeen = parseTimestampMs(row.last_seen_at);
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > PRESENT_VISITOR_SECONDS * 1000) return "offline";
const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at);
return Number.isFinite(lastActivity) && Date.now() - lastActivity <= ACTIVE_VISITOR_SECONDS * 1000 ? "active" : "idle";
}
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),
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.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, 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 = "";
return {
id: site.id,
siteKey: site.site_key,
name: site.name,
domain: site.domain,
isOnline: Boolean(site.is_online),
settings
};
}
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) {
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(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
) AS visitor_online,
COALESCE((
SELECT CASE
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} 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) {
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",
"pingdom", "uptimerobot", "curl", "wget", "python-requests", "axios", "httpclient"
];
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" };
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;
}
}