Add Telegram mobile replies

This commit is contained in:
rajchales-monito
2026-07-30 10:41:18 +02:00
parent c85d032a6d
commit d239131741
4 changed files with 317 additions and 2 deletions
+3
View File
@@ -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;
+19
View File
@@ -226,6 +226,25 @@
<button id="testOpenAiKey" type="button">Otestovat klic</button>
</div>
</fieldset>
<fieldset class="effect-settings telegram-settings">
<legend>Telegram</legend>
<p class="ai-help">
Bot vytvoris v Telegramu pres BotFather. Webhook URL zkopiruj do setWebhook nebo ji pouzijeme pri nastaveni serveru.
</p>
<label><input id="telegramEnabled" type="checkbox"> zapnout Telegram</label>
<label>Jmeno odpovedi <input id="telegramOperatorName" autocomplete="off" placeholder="Alex"></label>
<label class="full-row">Bot token
<input id="telegramBotToken" type="password" autocomplete="off" placeholder="123456:ABC...">
<small id="telegramStatus">Telegram neni nastaveny.</small>
</label>
<label class="full-row">Chat ID <input id="telegramChatId" autocomplete="off" placeholder="123456789"></label>
<label class="full-row">Webhook secret <input id="telegramWebhookSecret" autocomplete="off" placeholder="nahodny-secret"></label>
<label class="full-row">Webhook URL <input id="telegramWebhookUrl" readonly></label>
<div class="ai-actions">
<button id="saveTelegramSettings" type="button">Ulozit Telegram</button>
<button id="testTelegram" type="button">Otestovat Telegram</button>
</div>
</fieldset>
<fieldset class="effect-settings admin-users-settings">
<legend>BO uzivatele</legend>
<form id="adminUserForm" class="admin-user-form">
+58
View File
@@ -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);
+237 -2
View File
@@ -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,