2026-05-13 10:11:22 -05:00
|
|
|
|
(() => {
|
|
|
|
|
|
const PLUGIN_ID = "chat";
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const MAX_ATTACHMENT_SIZE = 64 * 1024 * 1024;
|
|
|
|
|
|
const MAX_ATTACHMENT_BASE64_SIZE = Math.ceil(MAX_ATTACHMENT_SIZE / 3) * 4;
|
|
|
|
|
|
const SAFE_IMAGE_MIMES = new Set([
|
|
|
|
|
|
"image/png",
|
|
|
|
|
|
"image/jpeg",
|
|
|
|
|
|
"image/webp",
|
|
|
|
|
|
]);
|
2026-05-18 23:18:57 -05:00
|
|
|
|
|
2026-05-13 10:11:22 -05:00
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
|
|
|
|
|
|
const clientIdInput = document.getElementById("client-id");
|
|
|
|
|
|
const operatorNameIn = document.getElementById("operator-name");
|
|
|
|
|
|
const targetNameIn = document.getElementById("target-name");
|
|
|
|
|
|
const windowTitleIn = document.getElementById("window-title");
|
|
|
|
|
|
const optClosable = document.getElementById("opt-closable");
|
|
|
|
|
|
const optOnTop = document.getElementById("opt-ontop");
|
|
|
|
|
|
const btnOpen = document.getElementById("btn-open");
|
|
|
|
|
|
const btnClose = document.getElementById("btn-close");
|
|
|
|
|
|
const btnClear = document.getElementById("btn-clear");
|
|
|
|
|
|
const statusDot = document.getElementById("status-dot");
|
|
|
|
|
|
const statusText = document.getElementById("status-text");
|
|
|
|
|
|
const messagesEl = document.getElementById("messages");
|
|
|
|
|
|
const msgInput = document.getElementById("msg-input");
|
|
|
|
|
|
const btnSend = document.getElementById("btn-send");
|
2026-05-18 23:18:57 -05:00
|
|
|
|
const btnAttach = document.getElementById("btn-attach");
|
|
|
|
|
|
const fileInput = document.getElementById("file-input");
|
2026-05-13 10:11:22 -05:00
|
|
|
|
const configToggle = document.getElementById("config-toggle");
|
|
|
|
|
|
const configBody = document.getElementById("config-body");
|
|
|
|
|
|
|
|
|
|
|
|
let chatOpen = false;
|
|
|
|
|
|
let sseStream = null;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
const attachmentUrlCache = new Map();
|
2026-05-13 10:11:22 -05:00
|
|
|
|
|
|
|
|
|
|
clientIdInput.value = params.get("clientId") || "";
|
|
|
|
|
|
|
|
|
|
|
|
function getClientId() {
|
|
|
|
|
|
return clientIdInput.value.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setStatus(open) {
|
|
|
|
|
|
chatOpen = open;
|
|
|
|
|
|
statusDot.className = "chat-status-dot" + (open ? " open" : "");
|
|
|
|
|
|
statusText.textContent = open ? "Chat is open" : "Chat not opened";
|
2026-05-18 23:18:57 -05:00
|
|
|
|
btnOpen.disabled = open;
|
|
|
|
|
|
btnClose.disabled = !open;
|
|
|
|
|
|
msgInput.disabled = !open;
|
|
|
|
|
|
btnSend.disabled = !open;
|
|
|
|
|
|
btnAttach.disabled = !open;
|
2026-05-13 10:11:22 -05:00
|
|
|
|
if (open) msgInput.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(ts) {
|
|
|
|
|
|
const d = new Date(ts);
|
|
|
|
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 23:18:57 -05:00
|
|
|
|
function humanSize(bytes) {
|
|
|
|
|
|
if (bytes < 1024) return bytes + " B";
|
|
|
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
|
|
|
|
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
2026-05-13 10:11:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 23:18:57 -05:00
|
|
|
|
function readFileAsBase64(file) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const r = new FileReader();
|
|
|
|
|
|
r.onload = () => {
|
|
|
|
|
|
const s = r.result;
|
|
|
|
|
|
const i = s.indexOf(",");
|
|
|
|
|
|
resolve(i < 0 ? s : s.slice(i + 1));
|
|
|
|
|
|
};
|
|
|
|
|
|
r.onerror = () => reject(r.error || new Error("read failed"));
|
|
|
|
|
|
r.readAsDataURL(file);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 09:35:03 -05:00
|
|
|
|
async function loadAttachmentBlob(id, expectedMime) {
|
2026-05-18 23:18:57 -05:00
|
|
|
|
if (attachmentUrlCache.has(id)) return attachmentUrlCache.get(id);
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const clientId = getClientId();
|
|
|
|
|
|
if (!clientId) return null;
|
|
|
|
|
|
const { ok, result } = await rpc("get_attachment", { id, clientId });
|
|
|
|
|
|
if (
|
|
|
|
|
|
!ok ||
|
|
|
|
|
|
!result?.ok ||
|
|
|
|
|
|
(SAFE_IMAGE_MIMES.has(expectedMime) && result.mime !== expectedMime) ||
|
|
|
|
|
|
typeof result.dataB64 !== "string" ||
|
|
|
|
|
|
result.dataB64.length === 0 ||
|
|
|
|
|
|
result.dataB64.length > MAX_ATTACHMENT_BASE64_SIZE
|
|
|
|
|
|
) return null;
|
|
|
|
|
|
let bin;
|
|
|
|
|
|
try {
|
|
|
|
|
|
bin = atob(result.dataB64);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bin.length === 0 || bin.length > MAX_ATTACHMENT_SIZE) return null;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
const bytes = new Uint8Array(bin.length);
|
|
|
|
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
|
|
|
|
const blob = new Blob([bytes], {
|
2026-08-24 09:35:03 -05:00
|
|
|
|
type: SAFE_IMAGE_MIMES.has(expectedMime) ? expectedMime : "application/octet-stream",
|
2026-05-18 23:18:57 -05:00
|
|
|
|
});
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
2026-08-24 09:35:03 -05:00
|
|
|
|
if (attachmentUrlCache.size >= 10) {
|
|
|
|
|
|
const oldestId = attachmentUrlCache.keys().next().value;
|
|
|
|
|
|
const oldestUrl = attachmentUrlCache.get(oldestId);
|
|
|
|
|
|
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
|
|
|
|
|
|
attachmentUrlCache.delete(oldestId);
|
|
|
|
|
|
}
|
2026-05-18 23:18:57 -05:00
|
|
|
|
attachmentUrlCache.set(id, url);
|
|
|
|
|
|
return url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 09:35:03 -05:00
|
|
|
|
function showImagePreview(url, name) {
|
|
|
|
|
|
const overlay = document.createElement("div");
|
|
|
|
|
|
overlay.className = "chat-image-preview";
|
|
|
|
|
|
overlay.setAttribute("role", "dialog");
|
|
|
|
|
|
overlay.setAttribute("aria-modal", "true");
|
|
|
|
|
|
overlay.setAttribute("aria-label", `Image preview: ${name}`);
|
|
|
|
|
|
|
|
|
|
|
|
const close = document.createElement("button");
|
|
|
|
|
|
close.type = "button";
|
|
|
|
|
|
close.className = "chat-image-preview-close";
|
|
|
|
|
|
close.setAttribute("aria-label", "Close image preview");
|
|
|
|
|
|
close.textContent = "×";
|
|
|
|
|
|
|
|
|
|
|
|
const image = document.createElement("img");
|
|
|
|
|
|
image.src = url;
|
|
|
|
|
|
image.alt = name;
|
|
|
|
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
|
|
|
|
overlay.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
const onKeyDown = (event) => {
|
|
|
|
|
|
if (event.key === "Escape") remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
close.addEventListener("click", remove);
|
|
|
|
|
|
overlay.addEventListener("click", (event) => {
|
|
|
|
|
|
if (event.target === overlay) remove();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
|
|
|
|
|
|
|
|
overlay.append(image, close);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
close.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 23:18:57 -05:00
|
|
|
|
function renderAttachment(att, messageId) {
|
|
|
|
|
|
const wrap = document.createElement("div");
|
|
|
|
|
|
wrap.className = "chat-msg-attachment";
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const mime = String(att?.mime || "").trim().toLowerCase();
|
|
|
|
|
|
const name = String(att?.name || "image").slice(0, 255);
|
|
|
|
|
|
const sizeValue = Number(att?.size);
|
|
|
|
|
|
const size = Number.isFinite(sizeValue) && sizeValue >= 0
|
|
|
|
|
|
? Math.min(sizeValue, MAX_ATTACHMENT_SIZE)
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
const isImage = SAFE_IMAGE_MIMES.has(mime) && size > 0 && size <= MAX_ATTACHMENT_SIZE;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
|
|
|
|
|
|
if (isImage) {
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const view = document.createElement("button");
|
|
|
|
|
|
view.type = "button";
|
|
|
|
|
|
view.className = "chat-msg-file";
|
|
|
|
|
|
view.textContent = `View image: ${name} (${humanSize(size)})`;
|
|
|
|
|
|
view.addEventListener("click", async () => {
|
|
|
|
|
|
view.disabled = true;
|
|
|
|
|
|
const url = await loadAttachmentBlob(messageId, mime);
|
|
|
|
|
|
if (url) showImagePreview(url, name);
|
|
|
|
|
|
view.disabled = false;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
});
|
2026-08-24 09:35:03 -05:00
|
|
|
|
wrap.appendChild(view);
|
2026-05-18 23:18:57 -05:00
|
|
|
|
} else {
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const download = document.createElement("button");
|
|
|
|
|
|
download.type = "button";
|
|
|
|
|
|
download.className = "chat-msg-file";
|
|
|
|
|
|
download.textContent = `Download: ${name} (${humanSize(size)})`;
|
|
|
|
|
|
download.addEventListener("click", async () => {
|
|
|
|
|
|
download.disabled = true;
|
|
|
|
|
|
const url = await loadAttachmentBlob(messageId, mime);
|
|
|
|
|
|
if (url) {
|
|
|
|
|
|
const anchor = document.createElement("a");
|
|
|
|
|
|
anchor.href = url;
|
|
|
|
|
|
anchor.download = name;
|
|
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
download.disabled = false;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
});
|
2026-08-24 09:35:03 -05:00
|
|
|
|
wrap.appendChild(download);
|
2026-05-18 23:18:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendMessage(msg) {
|
|
|
|
|
|
const isOut = msg.direction === "to_target";
|
2026-05-13 10:11:22 -05:00
|
|
|
|
const div = document.createElement("div");
|
|
|
|
|
|
div.className = "chat-msg " + (isOut ? "outgoing" : "incoming");
|
|
|
|
|
|
|
|
|
|
|
|
const senderDiv = document.createElement("div");
|
|
|
|
|
|
senderDiv.className = "chat-msg-sender";
|
2026-05-18 23:18:57 -05:00
|
|
|
|
senderDiv.textContent = msg.sender;
|
|
|
|
|
|
div.appendChild(senderDiv);
|
2026-05-13 10:11:22 -05:00
|
|
|
|
|
2026-05-18 23:18:57 -05:00
|
|
|
|
if (msg.text) {
|
|
|
|
|
|
const textDiv = document.createElement("div");
|
|
|
|
|
|
textDiv.className = "chat-msg-text";
|
|
|
|
|
|
textDiv.textContent = msg.text;
|
|
|
|
|
|
div.appendChild(textDiv);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.attachment) {
|
|
|
|
|
|
div.appendChild(renderAttachment(msg.attachment, msg.id));
|
|
|
|
|
|
}
|
2026-05-13 10:11:22 -05:00
|
|
|
|
|
|
|
|
|
|
const timeDiv = document.createElement("div");
|
|
|
|
|
|
timeDiv.className = "chat-msg-time";
|
2026-05-18 23:18:57 -05:00
|
|
|
|
timeDiv.textContent = formatTime(msg.timestamp || Date.now());
|
2026-05-13 10:11:22 -05:00
|
|
|
|
div.appendChild(timeDiv);
|
2026-05-18 23:18:57 -05:00
|
|
|
|
|
2026-05-13 10:11:22 -05:00
|
|
|
|
messagesEl.appendChild(div);
|
|
|
|
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function rpc(method, rpcParams) {
|
|
|
|
|
|
const res = await fetch(`/api/plugins/${PLUGIN_ID}/rpc`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ method, params: rpcParams }),
|
|
|
|
|
|
});
|
|
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendPluginEvent(clientId, event, payload) {
|
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
|
`/api/clients/${encodeURIComponent(clientId)}/plugins/${PLUGIN_ID}/event`,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ event, payload }),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const t = await res.text();
|
|
|
|
|
|
throw new Error(`Event failed: ${res.status} ${t}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadHistory() {
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
if (!cid) return;
|
|
|
|
|
|
const { ok, result } = await rpc("get_history", { clientId: cid });
|
|
|
|
|
|
if (!ok || !result) return;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
|
|
|
|
|
attachmentUrlCache.clear();
|
2026-05-13 10:11:22 -05:00
|
|
|
|
messagesEl.innerHTML = "";
|
2026-05-18 23:18:57 -05:00
|
|
|
|
for (const m of result) appendMessage(m);
|
2026-05-13 10:11:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connectSSE() {
|
|
|
|
|
|
if (sseStream) sseStream.close();
|
2026-08-24 09:35:03 -05:00
|
|
|
|
const clientId = getClientId();
|
|
|
|
|
|
if (!clientId) return;
|
|
|
|
|
|
sseStream = new EventSource(
|
|
|
|
|
|
`/api/plugins/${PLUGIN_ID}/stream?clientId=${encodeURIComponent(clientId)}`,
|
|
|
|
|
|
);
|
2026-05-13 10:11:22 -05:00
|
|
|
|
|
|
|
|
|
|
sseStream.addEventListener("new_message", (e) => {
|
|
|
|
|
|
const data = JSON.parse(e.data);
|
|
|
|
|
|
if (data.clientId !== getClientId()) return;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
appendMessage(data);
|
2026-05-13 10:11:22 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sseStream.addEventListener("chat_status", (e) => {
|
|
|
|
|
|
const data = JSON.parse(e.data);
|
|
|
|
|
|
if (data.clientId !== getClientId()) return;
|
|
|
|
|
|
setStatus(data.status === "opened");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sseStream.addEventListener("history_cleared", (e) => {
|
|
|
|
|
|
const data = JSON.parse(e.data);
|
|
|
|
|
|
if (data.clientId !== getClientId()) return;
|
2026-05-18 23:18:57 -05:00
|
|
|
|
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
|
|
|
|
|
attachmentUrlCache.clear();
|
2026-05-13 10:11:22 -05:00
|
|
|
|
messagesEl.innerHTML = "";
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Event handlers ---
|
|
|
|
|
|
|
|
|
|
|
|
btnOpen.addEventListener("click", async () => {
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
if (!cid) { alert("Enter a Client ID first."); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendPluginEvent(cid, "open_chat", {
|
|
|
|
|
|
operatorName: operatorNameIn.value.trim() || "Operator",
|
|
|
|
|
|
targetName: targetNameIn.value.trim() || "User",
|
|
|
|
|
|
title: windowTitleIn.value.trim() || "Chat",
|
|
|
|
|
|
closable: optClosable.checked,
|
|
|
|
|
|
alwaysOnTop: optOnTop.checked,
|
|
|
|
|
|
});
|
|
|
|
|
|
setStatus(true);
|
|
|
|
|
|
loadHistory();
|
|
|
|
|
|
connectSSE();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert("Failed to open chat: " + err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
btnClose.addEventListener("click", async () => {
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
if (!cid) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendPluginEvent(cid, "close_chat", {});
|
|
|
|
|
|
setStatus(false);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert("Failed to close chat: " + err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
btnSend.addEventListener("click", async () => {
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
const text = msgInput.value.trim();
|
|
|
|
|
|
if (!cid || !text) return;
|
|
|
|
|
|
|
|
|
|
|
|
const sender = operatorNameIn.value.trim() || "Operator";
|
|
|
|
|
|
msgInput.value = "";
|
|
|
|
|
|
msgInput.focus();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await rpc("store_message", { clientId: cid, sender, text });
|
|
|
|
|
|
await sendPluginEvent(cid, "chat_message", { from: sender, text });
|
|
|
|
|
|
} catch (err) {
|
2026-05-18 23:18:57 -05:00
|
|
|
|
appendMessage({
|
|
|
|
|
|
sender: "System",
|
|
|
|
|
|
text: "Failed to send: " + err.message,
|
|
|
|
|
|
direction: "from_target",
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
});
|
2026-05-13 10:11:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
msgInput.addEventListener("keydown", (e) => {
|
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
btnSend.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 23:18:57 -05:00
|
|
|
|
btnAttach.addEventListener("click", () => fileInput.click());
|
|
|
|
|
|
|
|
|
|
|
|
fileInput.addEventListener("change", async () => {
|
|
|
|
|
|
const file = fileInput.files?.[0];
|
|
|
|
|
|
fileInput.value = "";
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
if (file.size > MAX_ATTACHMENT_SIZE) {
|
|
|
|
|
|
alert(`File is too large (${humanSize(file.size)}). Max is ${humanSize(MAX_ATTACHMENT_SIZE)}.`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
if (!cid) { alert("Enter a Client ID first."); return; }
|
|
|
|
|
|
|
|
|
|
|
|
const sender = operatorNameIn.value.trim() || "Operator";
|
|
|
|
|
|
const prevDisabled = btnAttach.disabled;
|
|
|
|
|
|
btnAttach.disabled = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const dataB64 = await readFileAsBase64(file);
|
2026-08-24 09:35:03 -05:00
|
|
|
|
// Some platforms leave File.type blank for otherwise valid images. The
|
|
|
|
|
|
// server detects the real raster format and returns its canonical MIME.
|
|
|
|
|
|
const mime = file.type || "";
|
|
|
|
|
|
const stored = await rpc("store_attachment", {
|
2026-05-18 23:18:57 -05:00
|
|
|
|
clientId: cid,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
mime,
|
|
|
|
|
|
dataB64,
|
|
|
|
|
|
});
|
2026-08-24 09:35:03 -05:00
|
|
|
|
if (!stored.ok || !stored.result?.ok) {
|
|
|
|
|
|
throw new Error(stored.result?.error || stored.error || "Attachment rejected");
|
|
|
|
|
|
}
|
2026-05-18 23:18:57 -05:00
|
|
|
|
await sendPluginEvent(cid, "chat_attachment", {
|
|
|
|
|
|
from: sender,
|
2026-08-24 09:35:03 -05:00
|
|
|
|
name: stored.result.name,
|
|
|
|
|
|
mime: stored.result.mime,
|
2026-05-18 23:18:57 -05:00
|
|
|
|
dataB64,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
appendMessage({
|
|
|
|
|
|
sender: "System",
|
|
|
|
|
|
text: "Failed to send file: " + err.message,
|
|
|
|
|
|
direction: "from_target",
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btnAttach.disabled = prevDisabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-13 10:11:22 -05:00
|
|
|
|
btnClear.addEventListener("click", async () => {
|
|
|
|
|
|
const cid = getClientId();
|
|
|
|
|
|
if (!cid) return;
|
|
|
|
|
|
await rpc("clear_history", { clientId: cid });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
configToggle.addEventListener("click", () => {
|
|
|
|
|
|
configToggle.classList.toggle("collapsed");
|
|
|
|
|
|
configBody.classList.toggle("hidden");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --- Init ---
|
|
|
|
|
|
|
|
|
|
|
|
if (getClientId()) {
|
|
|
|
|
|
loadHistory();
|
|
|
|
|
|
connectSSE();
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|