2121 lines
90 KiB
JavaScript
2121 lines
90 KiB
JavaScript
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 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.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);
|
|
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: {
|
|
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: ""
|
|
},
|
|
telegram: {
|
|
enabled: false,
|
|
botToken: TELEGRAM_BOT_TOKEN,
|
|
chatId: TELEGRAM_CHAT_ID,
|
|
webhookSecret: TELEGRAM_WEBHOOK_SECRET || id("tgsec"),
|
|
operatorName: "Alex"
|
|
}
|
|
};
|
|
}
|
|
|
|
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 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);
|
|
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) });
|
|
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" });
|
|
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("");
|
|
}
|
|
|
|
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: "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 handleTelegramUpdate(update, settings) {
|
|
const message = update?.message;
|
|
if (!message || !message.text || !message.reply_to_message) return;
|
|
const chatId = String(message.chat?.id || "");
|
|
if (String(settings.chatId) && chatId !== String(settings.chatId)) return;
|
|
const replyToMessageId = Number(message.reply_to_message.message_id);
|
|
const link = 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, replyToMessageId);
|
|
if (!link) {
|
|
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
|
|
});
|
|
await telegramSendMessage(settings, { chatId, text: "MaalFlows: odpoved odeslana zakaznikovi." });
|
|
}
|
|
|
|
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,
|
|
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 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` : "",
|
|
"",
|
|
"Odpovez pres Reply na tuto zpravu."
|
|
];
|
|
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;
|
|
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 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 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);
|
|
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 snippetFor(site) {
|
|
const settings = parseJson(site.settings_json, defaultSettings());
|
|
const languageSettings = normalizeWidgetLanguageSettings(settings.widgetLanguage);
|
|
const snippetLanguage = languageSettings.defaultLanguage || "cs";
|
|
return `<script>
|
|
window.MaalFlows = {
|
|
siteKey: "${site.site_key}",
|
|
lang: "${snippetLanguage}",
|
|
apiBase: "${PUBLIC_BASE_URL}"
|
|
};
|
|
</script>
|
|
<script async src="${PUBLIC_BASE_URL}/widget.js"></script>`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|