Add Telegram mobile replies
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user