From 450bdd9a9b9316c73d18c66fc91cb829b65f055f Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Wed, 29 Jul 2026 19:58:55 +0200 Subject: [PATCH] Add reliable widget message outbox --- public/widget.js | 101 +++++++++++++++++++++++++++++++++++++++++++---- server.js | 45 ++++++++++++++++++--- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/public/widget.js b/public/widget.js index f72e3bd..b710242 100644 --- a/public/widget.js +++ b/public/widget.js @@ -4,6 +4,7 @@ const siteKey = boot.siteKey || "9b-plus"; const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase(); const storageKey = `maalflows:${siteKey}`; + const pendingStorageKey = `${storageKey}:pending`; const session = loadSession(); const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const maxAttachmentBytes = 3_000_000; @@ -148,14 +149,13 @@ event.preventDefault(); const body = await payloadFromForm(form, selectedFiles); if (!body.message.trim() && !body.attachments.length) return; + const pendingMessage = queuePendingMessage(body); const submitButton = root.querySelector(".mf-send"); submitButton.disabled = true; - const url = session.conversationId - ? `${apiBase}/api/widget/conversations/${session.conversationId}/messages` - : `${apiBase}/api/widget/conversations`; try { - const data = await postJson(url, withVisitorContext(body)); + const data = await sendPendingMessage(pendingMessage); + removePendingMessage(pendingMessage.clientMessageId); session.conversationId = data.conversation.id; saveSession(); form.message.value = ""; @@ -164,6 +164,7 @@ handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false }); connectEvents(messages, typing, panel, launcher, settings, messageEffectState); startConversationPolling(messages, panel, launcher, settings, messageEffectState); + flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } catch { showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu."); } finally { @@ -179,8 +180,10 @@ if (!document.hidden) { noteActivity(); pingVisitor(); + flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } }); + window.addEventListener("online", () => flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState)); if (session.conversationId) { loadExistingConversation(messages, panel) .then((data) => { @@ -189,7 +192,10 @@ .finally(() => { connectEvents(messages, typing, panel, launcher, settings, messageEffectState); startConversationPolling(messages, panel, launcher, settings, messageEffectState); + flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); }); + } else { + flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } } @@ -225,6 +231,51 @@ }; } + async function flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState) { + if (session.flushingPending) return; + const pendingMessages = loadPendingMessages(); + if (!pendingMessages.length) return; + session.flushingPending = true; + try { + for (const pendingMessage of pendingMessages) { + try { + const data = await sendPendingMessage(pendingMessage); + removePendingMessage(pendingMessage.clientMessageId); + session.conversationId = data.conversation.id; + saveSession(); + handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false }); + connectEvents(messages, typing, panel, launcher, settings, messageEffectState); + startConversationPolling(messages, panel, launcher, settings, messageEffectState); + } catch { + break; + } + } + } finally { + session.flushingPending = false; + } + } + + async function sendPendingMessage(pendingMessage) { + const { conversationId, ...body } = pendingMessage; + return postWidgetMessage(body, conversationId || session.conversationId); + } + + async function postWidgetMessage(body, conversationId) { + const url = conversationId + ? `${apiBase}/api/widget/conversations/${conversationId}/messages` + : `${apiBase}/api/widget/conversations`; + try { + return await postJson(url, withVisitorContext(body)); + } catch (error) { + if (conversationId && (error.status === 403 || error.status === 404)) { + session.conversationId = null; + saveSession(); + return postJson(`${apiBase}/api/widget/conversations`, withVisitorContext(body)); + } + throw error; + } + } + function createHeaderActions(root, closeButton) { const actions = document.createElement("div"); actions.className = "mf-header-actions"; @@ -605,13 +656,49 @@ })); } + function queuePendingMessage(body) { + const pendingMessage = { + ...body, + conversationId: session.conversationId, + clientMessageId: body.clientMessageId || randomId("msg"), + queuedAt: new Date().toISOString() + }; + const pendingMessages = loadPendingMessages().filter((item) => item.clientMessageId !== pendingMessage.clientMessageId); + pendingMessages.push(pendingMessage); + savePendingMessages(pendingMessages.slice(-10)); + return pendingMessage; + } + + function removePendingMessage(clientMessageId) { + savePendingMessages(loadPendingMessages().filter((item) => item.clientMessageId !== clientMessageId)); + } + + function loadPendingMessages() { + try { + const items = JSON.parse(localStorage.getItem(pendingStorageKey) || "[]"); + return Array.isArray(items) ? items.filter((item) => item?.clientMessageId) : []; + } catch { + return []; + } + } + + function savePendingMessages(messages) { + try { + localStorage.setItem(pendingStorageKey, JSON.stringify(messages)); + } catch {} + } + function postJson(url, payload) { return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + const error = new Error(`HTTP ${res.status}`); + error.status = res.status; + throw error; + } return res.json(); }); } @@ -649,7 +736,7 @@ })[char]); } - function randomId() { - return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; + function randomId(prefix = "vis") { + return `${prefix}_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; } })(); diff --git a/server.js b/server.js index 7bceedf..209d03f 100644 --- a/server.js +++ b/server.js @@ -153,6 +153,7 @@ function initDb() { conversation_id INTEGER NOT NULL REFERENCES conversations(id), sender_type TEXT NOT NULL, sender_name TEXT, + client_message_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -211,6 +212,8 @@ function initDb() { 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("messages", "client_message_id", "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) { @@ -523,6 +526,9 @@ async function createConversation(req, res) { 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); @@ -552,7 +558,7 @@ async function createConversation(req, res) { nullish(countryFromHeaders(req)) ).lastInsertRowid; - const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message || "").trim()); + const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message || "").trim(), clientMessageId); saveAttachments(conversationId, messageId, body.attachments || []); return conversationId; }); @@ -570,7 +576,12 @@ async function createVisitorMessage(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, "visitor", conversation.visitor_name || "Visitor", String(body.message || "").trim()); + 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); markConversationUnread(conversation.id); @@ -667,11 +678,33 @@ function publish(key, event) { } } -function insertMessage(conversationId, senderType, senderName, body) { +function insertMessage(conversationId, senderType, senderName, body, clientMessageId = null) { return db.prepare(` - INSERT INTO messages (conversation_id, sender_type, sender_name, body) - VALUES (?, ?, ?, ?) - `).run(conversationId, senderType, senderName, body).lastInsertRowid; + INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body) + VALUES (?, ?, ?, ?, ?) + `).run(conversationId, senderType, senderName, clientMessageId, body).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; } function saveAttachments(conversationId, messageId, attachments) {