From d239131741a1cfcf168a290b60dde15c7bc6fb85 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 30 Jul 2026 10:41:18 +0200 Subject: [PATCH] Add Telegram mobile replies --- public/admin.css | 3 + public/admin.html | 19 ++++ public/admin.js | 58 +++++++++++ server.js | 239 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 317 insertions(+), 2 deletions(-) diff --git a/public/admin.css b/public/admin.css index 457a065..95faf30 100644 --- a/public/admin.css +++ b/public/admin.css @@ -333,6 +333,9 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } display: grid; align-items: stretch; } +.telegram-settings input { + width: 100%; +} .settings-actions { display: flex; align-items: center; diff --git a/public/admin.html b/public/admin.html index 9981f31..7fee1d8 100644 --- a/public/admin.html +++ b/public/admin.html @@ -226,6 +226,25 @@ +
+ Telegram +

+ Bot vytvoris v Telegramu pres BotFather. Webhook URL zkopiruj do setWebhook nebo ji pouzijeme pri nastaveni serveru. +

+ + + + + + +
+ + +
+
BO uzivatele
diff --git a/public/admin.js b/public/admin.js index 63ddc5d..b6edf9b 100644 --- a/public/admin.js +++ b/public/admin.js @@ -97,6 +97,13 @@ $("#openaiApiKey").addEventListener("input", markAiSettingsDirty); $("#refreshAiModels").addEventListener("click", loadAiModels); $("#saveAiSettings").addEventListener("click", saveTranslationSettings); $("#testOpenAiKey").addEventListener("click", testOpenAiKey); +$("#telegramEnabled").addEventListener("change", markTelegramSettingsDirty); +$("#telegramOperatorName").addEventListener("input", markTelegramSettingsDirty); +$("#telegramBotToken").addEventListener("input", markTelegramSettingsDirty); +$("#telegramChatId").addEventListener("input", markTelegramSettingsDirty); +$("#telegramWebhookSecret").addEventListener("input", markTelegramSettingsDirty); +$("#saveTelegramSettings").addEventListener("click", saveTelegramSettings); +$("#testTelegram").addEventListener("click", testTelegram); $("#adminUserForm").addEventListener("submit", createAdminUser); $("#adminUsersList").addEventListener("click", handleAdminUserClick); document.querySelectorAll("[data-settings-tab]").forEach((button) => { @@ -473,6 +480,7 @@ function renderSite() { ? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})` : "Klic neni ulozeny. Bez nej AI preklady nepobezi."; renderModelOptions(s.translations?.model || "gpt-5-mini"); + renderTelegramSettings(s.telegram || {}); } function renderWidgetLanguageOptions(form, settings) { @@ -707,6 +715,56 @@ function setOpenAiStatus(message) { $("#openaiApiKeyStatus").textContent = message; } +function renderTelegramSettings(settings) { + $("#telegramEnabled").checked = Boolean(settings.enabled); + $("#telegramOperatorName").value = settings.operatorName || "Alex"; + $("#telegramBotToken").value = ""; + $("#telegramChatId").value = settings.chatId || ""; + $("#telegramWebhookSecret").value = settings.webhookSecret || ""; + $("#telegramWebhookUrl").value = settings.webhookUrl || ""; + setTelegramStatus(settings.hasBotToken + ? `Token ulozeny (${settings.botTokenMasked || "token..."})` + : "Token neni ulozeny. Bez nej Telegram nepobezi."); +} + +function markTelegramSettingsDirty() { + setTelegramStatus("Telegram nastaveni ma neulozene zmeny."); +} + +async function saveTelegramSettings() { + if (!state.site) return; + setTelegramStatus("Ukladam Telegram..."); + const settings = structuredClone(state.site.settings); + settings.telegram = { + ...(settings.telegram || {}), + enabled: $("#telegramEnabled").checked, + chatId: $("#telegramChatId").value.trim(), + webhookSecret: $("#telegramWebhookSecret").value.trim(), + operatorName: $("#telegramOperatorName").value.trim() || "Alex" + }; + const botToken = $("#telegramBotToken").value.trim(); + if (botToken) settings.telegram.botToken = botToken; + await saveSite({ settings, isOnline: state.site.isOnline }); + $("#telegramBotToken").value = ""; + renderTelegramSettings(state.site.settings.telegram || {}); +} + +async function testTelegram() { + await saveTelegramSettings(); + setTelegramStatus("Posilam test Telegramu..."); + const result = await api("/api/admin/telegram/test", { method: "POST" }).catch((error) => error); + if (result?.ok) { + if (result.webhookUrl) $("#telegramWebhookUrl").value = result.webhookUrl; + setTelegramStatus("Telegram funguje. Testovaci zprava odeslana a webhook nastaven."); + return; + } + setTelegramStatus(`Telegram test selhal: ${shortErrorMessage(result?.message || result?.error || "neznamá chyba")}`); +} + +function setTelegramStatus(message) { + $("#telegramStatus").textContent = message; +} + async function loadAiModels() { const current = $("#translationModel").value || state.site?.settings?.translations?.model || "gpt-5-mini"; const data = await api("/api/admin/ai/models").catch((error) => error?.models ? error : null); diff --git a/server.js b/server.js index fe19989..f9e8088 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,9 @@ 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; @@ -70,6 +73,8 @@ async function route(req, res) { } 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)); @@ -97,6 +102,7 @@ async function adminApi(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); @@ -224,10 +230,22 @@ function initDb() { 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(); @@ -334,6 +352,13 @@ function defaultSettings() { 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" } }; } @@ -677,6 +702,39 @@ async function adminAiTest(res) { } } +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; @@ -818,6 +876,7 @@ async function createConversation(req, res) { 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)); } @@ -840,6 +899,7 @@ async function createVisitorMessage(req, res, url) { 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)); } @@ -860,14 +920,23 @@ async function createAdminMessage(req, res, url) { if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" }); const conversation = findConversation(publicIdFrom(url)); if (!conversation) return json(res, 404, { error: "conversation_not_found" }); - const messageId = insertMessage(conversation.id, "admin", req.adminUser.chatNick || req.adminUser.displayName || req.adminUser.username, String(body.message || "").trim()); + 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 json(res, 201, conversationDetails(updated)); + return updated; } function deleteAttachment(res, url) { @@ -1090,6 +1159,117 @@ function outputTextFromResponse(data) { 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 lines = [ + `MaalFlows: nova zprava`, + `${conversation.countryFlag || ""} ${conversation.visitorLabel || "Navstevnik"} · ${conversation.customerLanguage || conversation.language || "jazyk ?"}`, + conversation.currentUrl ? `Stranka: ${shortText(conversation.currentUrl, 160)}` : "", + conversation.referrer ? `Prislel z: ${shortText(conversation.referrer, 120)}` : "", + "", + message.body || (attachmentCount ? "(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 || {}); @@ -1109,6 +1289,22 @@ function mergeSiteSettings(currentSettings, incomingSettings) { 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; } @@ -1179,6 +1375,19 @@ function getTranslationSettings() { }; } +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); @@ -1317,6 +1526,24 @@ function maskApiKey(value) { 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, @@ -1545,6 +1772,14 @@ function formatSite(site) { 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,