Add reliable widget message outbox
This commit is contained in:
+94
-7
@@ -4,6 +4,7 @@
|
|||||||
const siteKey = boot.siteKey || "9b-plus";
|
const siteKey = boot.siteKey || "9b-plus";
|
||||||
const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase();
|
const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase();
|
||||||
const storageKey = `maalflows:${siteKey}`;
|
const storageKey = `maalflows:${siteKey}`;
|
||||||
|
const pendingStorageKey = `${storageKey}:pending`;
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||||
const maxAttachmentBytes = 3_000_000;
|
const maxAttachmentBytes = 3_000_000;
|
||||||
@@ -148,14 +149,13 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const body = await payloadFromForm(form, selectedFiles);
|
const body = await payloadFromForm(form, selectedFiles);
|
||||||
if (!body.message.trim() && !body.attachments.length) return;
|
if (!body.message.trim() && !body.attachments.length) return;
|
||||||
|
const pendingMessage = queuePendingMessage(body);
|
||||||
const submitButton = root.querySelector(".mf-send");
|
const submitButton = root.querySelector(".mf-send");
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
|
|
||||||
const url = session.conversationId
|
|
||||||
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
|
|
||||||
: `${apiBase}/api/widget/conversations`;
|
|
||||||
try {
|
try {
|
||||||
const data = await postJson(url, withVisitorContext(body));
|
const data = await sendPendingMessage(pendingMessage);
|
||||||
|
removePendingMessage(pendingMessage.clientMessageId);
|
||||||
session.conversationId = data.conversation.id;
|
session.conversationId = data.conversation.id;
|
||||||
saveSession();
|
saveSession();
|
||||||
form.message.value = "";
|
form.message.value = "";
|
||||||
@@ -164,6 +164,7 @@
|
|||||||
handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false });
|
handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false });
|
||||||
connectEvents(messages, typing, panel, launcher, settings, messageEffectState);
|
connectEvents(messages, typing, panel, launcher, settings, messageEffectState);
|
||||||
startConversationPolling(messages, panel, launcher, settings, messageEffectState);
|
startConversationPolling(messages, panel, launcher, settings, messageEffectState);
|
||||||
|
flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState);
|
||||||
} catch {
|
} catch {
|
||||||
showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu.");
|
showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,8 +180,10 @@
|
|||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
noteActivity();
|
noteActivity();
|
||||||
pingVisitor();
|
pingVisitor();
|
||||||
|
flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.addEventListener("online", () => flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState));
|
||||||
if (session.conversationId) {
|
if (session.conversationId) {
|
||||||
loadExistingConversation(messages, panel)
|
loadExistingConversation(messages, panel)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -189,7 +192,10 @@
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
connectEvents(messages, typing, panel, launcher, settings, messageEffectState);
|
connectEvents(messages, typing, panel, launcher, settings, messageEffectState);
|
||||||
startConversationPolling(messages, 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) {
|
function createHeaderActions(root, closeButton) {
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "mf-header-actions";
|
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) {
|
function postJson(url, payload) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}).then((res) => {
|
}).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();
|
return res.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -649,7 +736,7 @@
|
|||||||
})[char]);
|
})[char]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomId() {
|
function randomId(prefix = "vis") {
|
||||||
return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
return `${prefix}_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ function initDb() {
|
|||||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||||
sender_type TEXT NOT NULL,
|
sender_type TEXT NOT NULL,
|
||||||
sender_name TEXT,
|
sender_name TEXT,
|
||||||
|
client_message_id TEXT,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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();
|
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", "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) {
|
function ensureColumn(table, column, definition) {
|
||||||
@@ -523,6 +526,9 @@ async function createConversation(req, res) {
|
|||||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||||
|
|
||||||
const visitorToken = body.visitorToken || id("vis");
|
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 publicId = id("cnv");
|
||||||
const info = body.visitorInfo || {};
|
const info = body.visitorInfo || {};
|
||||||
const ip = clientIp(req);
|
const ip = clientIp(req);
|
||||||
@@ -552,7 +558,7 @@ async function createConversation(req, res) {
|
|||||||
nullish(countryFromHeaders(req))
|
nullish(countryFromHeaders(req))
|
||||||
).lastInsertRowid;
|
).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 || []);
|
saveAttachments(conversationId, messageId, body.attachments || []);
|
||||||
return conversationId;
|
return conversationId;
|
||||||
});
|
});
|
||||||
@@ -570,7 +576,12 @@ async function createVisitorMessage(req, res, url) {
|
|||||||
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
|
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
|
||||||
const conversation = findConversation(publicIdFrom(url));
|
const conversation = findConversation(publicIdFrom(url));
|
||||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
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 || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
updateVisitorContext(conversation.id, body);
|
updateVisitorContext(conversation.id, body);
|
||||||
markConversationUnread(conversation.id);
|
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(`
|
return db.prepare(`
|
||||||
INSERT INTO messages (conversation_id, sender_type, sender_name, body)
|
INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(conversationId, senderType, senderName, body).lastInsertRowid;
|
`).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) {
|
function saveAttachments(conversationId, messageId, attachments) {
|
||||||
|
|||||||
Reference in New Issue
Block a user